RcmEmailAutomation / sa-orchestration /includes /class-sa-asterion-explorer.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
12.4 kB
<?php
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> &nbsp;·&nbsp; 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>';
}
}