Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Asterion Explorer — read-only admin surface for the canonical spec pack. | |
| * | |
| * Source of truth: F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs/ | |
| * (filterable via 'sa_orch_asterion_spec_root'). | |
| * | |
| * This is a MIRROR of the repo. The repo remains authoritative. | |
| * - read-only in WP | |
| * - no edits | |
| * - no consolidation | |
| * - no reclassification | |
| * - no mixing with other MD sources | |
| * | |
| * Distinct from SA_Orch_Asterion (the dormant ledger writer that targets | |
| * wp-content/sa-asterion/). The Explorer is human-facing canonical doctrine. | |
| * The ledger (when activated) is system-generated event records. Different | |
| * surfaces, different concerns; they coexist without entanglement. | |
| * | |
| * Mirror mechanism: live filesystem read at request time. | |
| * - No filesystem copy — no drift potential | |
| * - No indexed snapshot table — no DB writes | |
| * - Repo is automatically authoritative because there is no second copy | |
| * | |
| * Sara awareness: NONE in this Board. Deferred to a future Sara Retrieval | |
| * Layer Board. The Explorer is the human-facing truth surface; cross-agent | |
| * retrieval is a separate concern with its own design constraints. | |
| */ | |
| class SA_Orch_Asterion_Explorer { | |
| const SUBMENU_SLUG = 'sa-orchestration-asterion'; | |
| /** Default repo path on this dev machine. Filterable. Override per-deploy via the filter. */ | |
| const DEFAULT_SPEC_ROOT = 'F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs'; | |
| /** | |
| * Absolute path to the spec pack root, normalized for this OS. | |
| * Filter: sa_orch_asterion_spec_root (string $absolute_path) | |
| */ | |
| public static function spec_root(): string { | |
| $root = apply_filters( 'sa_orch_asterion_spec_root', self::DEFAULT_SPEC_ROOT ); | |
| return rtrim( str_replace( '\\', '/', (string) $root ), '/' ); | |
| } | |
| /** Hook into admin_menu via the existing SA_Orch_Admin parent. */ | |
| public static function register_menu() { | |
| add_submenu_page( | |
| SA_Orch_Admin::PARENT_SLUG, | |
| 'Asterion', | |
| 'Asterion', | |
| SA_Orch_Admin::CAP, | |
| self::SUBMENU_SLUG, | |
| [ __CLASS__, 'render_page' ] | |
| ); | |
| } | |
| /** | |
| * Render the explorer page: file tree on the left, MD render on the right. | |
| */ | |
| public static function render_page() { | |
| if ( ! current_user_can( SA_Orch_Admin::CAP ) ) { | |
| wp_die( __( 'Insufficient permissions.', 'sa-orchestration' ) ); | |
| } | |
| $root = self::spec_root(); | |
| $tree = self::build_tree( $root ); | |
| // Selected file from query string. Validated below; never touched | |
| // without going through validate_relative_path() first. | |
| $requested = isset( $_GET['file'] ) ? wp_unslash( (string) $_GET['file'] ) : ''; | |
| $resolved = $requested !== '' ? self::validate_relative_path( $requested ) : null; | |
| echo '<div class="wrap sa-asterion-wrap">'; | |
| echo '<h1>Asterion <span class="sa-asterion-tag">read-only canonical truth surface</span></h1>'; | |
| echo '<p class="sa-asterion-meta">Source: <code>' . esc_html( $root ) . '</code> · Repo is authoritative. No edits from WP.</p>'; | |
| echo self::inline_styles(); | |
| echo '<div class="sa-asterion-explorer">'; | |
| // ---- Tree ---- | |
| echo '<aside class="sa-asterion-tree">'; | |
| if ( empty( $tree ) ) { | |
| echo '<p class="sa-asterion-empty">No .md files found at the configured spec root. Verify <code>sa_orch_asterion_spec_root</code> filter / default path is reachable from the WP install.</p>'; | |
| } else { | |
| self::render_tree_html( $tree, '', $resolved !== null ? $resolved['relative'] : '' ); | |
| } | |
| echo '</aside>'; | |
| // ---- Content ---- | |
| echo '<section class="sa-asterion-content">'; | |
| if ( $requested !== '' && $resolved === null ) { | |
| echo '<div class="sa-asterion-error"><strong>Invalid file path.</strong> The requested file is outside the spec root, has a non-.md extension, contains path traversal, or does not exist.</div>'; | |
| } elseif ( $resolved === null ) { | |
| echo '<div class="sa-asterion-placeholder">Select a file from the tree to view its contents.</div>'; | |
| } else { | |
| $rendered = self::render_markdown_file( $resolved['absolute'] ); | |
| if ( $rendered === null ) { | |
| echo '<div class="sa-asterion-error"><strong>Read failed.</strong> The file resolved but could not be read.</div>'; | |
| } else { | |
| echo '<div class="sa-asterion-breadcrumb"><code>' . esc_html( $resolved['relative'] ) . '</code></div>'; | |
| echo '<article class="sa-asterion-md">' . $rendered . '</article>'; | |
| } | |
| } | |
| echo '</section>'; | |
| echo '</div>'; // .sa-asterion-explorer | |
| echo '</div>'; // .wrap | |
| } | |
| /** | |
| * Build a nested array tree of the spec pack. | |
| * Returns: [ 'dirs' => [ name => sub-tree ], 'files' => [ name => relative_path ] ] | |
| */ | |
| private static function build_tree( string $root ): array { | |
| if ( ! is_dir( $root ) ) { | |
| return []; | |
| } | |
| return self::walk( $root, '' ); | |
| } | |
| private static function walk( string $abs, string $rel_prefix ): array { | |
| $dirs = []; | |
| $files = []; | |
| $entries = @scandir( $abs ); | |
| if ( $entries === false ) { | |
| return [ 'dirs' => [], 'files' => [] ]; | |
| } | |
| foreach ( $entries as $entry ) { | |
| if ( $entry === '.' || $entry === '..' ) continue; | |
| $abs_path = $abs . '/' . $entry; | |
| $rel_path = $rel_prefix === '' ? $entry : $rel_prefix . '/' . $entry; | |
| if ( is_dir( $abs_path ) ) { | |
| $dirs[ $entry ] = self::walk( $abs_path, $rel_path ); | |
| } elseif ( is_file( $abs_path ) && substr( strtolower( $entry ), -3 ) === '.md' ) { | |
| $files[ $entry ] = $rel_path; | |
| } | |
| } | |
| ksort( $dirs ); | |
| ksort( $files ); | |
| return [ 'dirs' => $dirs, 'files' => $files ]; | |
| } | |
| private static function render_tree_html( array $tree, string $current_dir_rel, string $selected ) { | |
| echo '<ul class="sa-asterion-list">'; | |
| foreach ( (array) ( $tree['dirs'] ?? [] ) as $name => $sub ) { | |
| echo '<li class="sa-asterion-dir"><span class="sa-asterion-dir-label">' . esc_html( $name ) . '/</span>'; | |
| self::render_tree_html( $sub, ( $current_dir_rel === '' ? $name : $current_dir_rel . '/' . $name ), $selected ); | |
| echo '</li>'; | |
| } | |
| foreach ( (array) ( $tree['files'] ?? [] ) as $name => $rel ) { | |
| $url = add_query_arg( [ | |
| 'page' => self::SUBMENU_SLUG, | |
| 'file' => $rel, | |
| ], admin_url( 'admin.php' ) ); | |
| $is_sel = ( $rel === $selected ); | |
| $cls = 'sa-asterion-file' . ( $is_sel ? ' sa-asterion-file-selected' : '' ); | |
| echo '<li class="' . esc_attr( $cls ) . '"><a href="' . esc_url( $url ) . '">' . esc_html( $name ) . '</a></li>'; | |
| } | |
| echo '</ul>'; | |
| } | |
| /** | |
| * Validate a relative path against the spec root. Returns | |
| * [ 'relative' => normalized, 'absolute' => realpath ] | |
| * or null on any failure (path traversal, wrong extension, outside root, | |
| * not a file). | |
| * | |
| * Public so other surfaces (Board #28 Sara file binding; | |
| * Board #30 Asterion projection — both reuse) can validate without | |
| * re-implementing the rules. | |
| */ | |
| public static function validate_relative_path( string $relative ): ?array { | |
| // Normalize separators | |
| $relative = str_replace( '\\', '/', $relative ); | |
| // Reject any obvious traversal or absolute paths | |
| if ( strpos( $relative, '..' ) !== false ) return null; | |
| if ( $relative === '' ) return null; | |
| if ( $relative[0] === '/' ) return null; | |
| if ( preg_match( '#^[A-Za-z]:#', $relative ) ) return null; | |
| // Whitelist .md extension only | |
| if ( substr( strtolower( $relative ), -3 ) !== '.md' ) return null; | |
| $root_real = realpath( self::spec_root() ); | |
| if ( $root_real === false ) return null; | |
| $root_real = str_replace( '\\', '/', $root_real ); | |
| $candidate = realpath( self::spec_root() . '/' . $relative ); | |
| if ( $candidate === false ) return null; | |
| $candidate = str_replace( '\\', '/', $candidate ); | |
| // Final containment check: candidate must be inside root | |
| if ( strpos( $candidate, $root_real . '/' ) !== 0 ) return null; | |
| if ( ! is_file( $candidate ) ) return null; | |
| // Re-derive normalized relative from the realpath for display | |
| $normalized_rel = ltrim( substr( $candidate, strlen( $root_real ) ), '/' ); | |
| return [ | |
| 'relative' => $normalized_rel, | |
| 'absolute' => $candidate, | |
| ]; | |
| } | |
| /** | |
| * Read the file from disk and render via Parsedown in safe mode. | |
| * Returns rendered HTML, or null on read failure. | |
| */ | |
| private static function render_markdown_file( string $absolute ): ?string { | |
| $content = @file_get_contents( $absolute ); | |
| if ( $content === false ) return null; | |
| if ( ! class_exists( 'Parsedown' ) ) { | |
| $vendor = dirname( __DIR__ ) . '/vendor/Parsedown.php'; | |
| if ( file_exists( $vendor ) ) { | |
| require_once $vendor; | |
| } | |
| } | |
| if ( ! class_exists( 'Parsedown' ) ) { | |
| // Last-resort fallback: HTML-escape and render in <pre>. Honest if ugly. | |
| return '<pre class="sa-asterion-fallback">' . esc_html( $content ) . '</pre>'; | |
| } | |
| $pd = new Parsedown(); | |
| if ( method_exists( $pd, 'setSafeMode' ) ) { | |
| $pd->setSafeMode( true ); | |
| } | |
| return $pd->text( $content ); | |
| } | |
| private static function inline_styles(): string { | |
| // Scoped styles. Keeping it inline so the Board ships in one file. | |
| return '<style> | |
| .sa-asterion-wrap h1 .sa-asterion-tag { font-size:11px; padding:2px 6px; background:#f0f0f1; color:#646970; border-radius:3px; vertical-align:middle; font-weight:normal; margin-left:8px; } | |
| .sa-asterion-meta { color:#646970; font-size:12px; } | |
| .sa-asterion-explorer { display:flex; gap:16px; margin-top:12px; align-items:flex-start; } | |
| .sa-asterion-tree { flex:0 0 280px; max-height:80vh; overflow:auto; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:12px 8px; } | |
| .sa-asterion-content { flex:1 1 auto; min-width:0; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:16px 24px; } | |
| .sa-asterion-list { margin:0; padding-left:14px; list-style:none; } | |
| .sa-asterion-list ul { padding-left:14px; } | |
| .sa-asterion-dir-label { font-weight:600; color:#1d2327; } | |
| .sa-asterion-file a { display:block; padding:2px 4px; text-decoration:none; color:#2271b1; } | |
| .sa-asterion-file a:hover { background:#f6f7f7; } | |
| .sa-asterion-file-selected a { background:#dcdcde; color:#1d2327; font-weight:600; } | |
| .sa-asterion-breadcrumb { font-size:12px; color:#646970; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid #f0f0f1; } | |
| .sa-asterion-placeholder, .sa-asterion-empty { color:#646970; font-style:italic; } | |
| .sa-asterion-error { background:#fcf0f1; border-left:4px solid #d63638; padding:10px 12px; color:#7a1f23; } | |
| .sa-asterion-md { line-height:1.6; } | |
| .sa-asterion-md h1, .sa-asterion-md h2, .sa-asterion-md h3 { margin-top:1.4em; } | |
| .sa-asterion-md pre { background:#1d2327; color:#f6f7f7; padding:12px; border-radius:4px; overflow:auto; } | |
| .sa-asterion-md code { background:#f0f0f1; padding:1px 5px; border-radius:3px; font-size:13px; } | |
| .sa-asterion-md pre code { background:transparent; padding:0; color:inherit; } | |
| .sa-asterion-md table { border-collapse:collapse; margin:1em 0; } | |
| .sa-asterion-md th, .sa-asterion-md td { border:1px solid #c3c4c7; padding:6px 10px; } | |
| .sa-asterion-md blockquote { border-left:4px solid #c3c4c7; margin:1em 0; padding:4px 14px; color:#50575e; } | |
| .sa-asterion-fallback { white-space:pre-wrap; } | |
| </style>'; | |
| } | |
| } | |