@@ -476,14 +476,9 @@ export function detectHostOpenClaw(env: NodeJS.ProcessEnv = process.env): HostOp
476476 } ;
477477}
478478
479- function computeFileDigest ( filePath : string ) : string | null {
480- try {
481- if ( ! existsSync ( filePath ) ) return null ;
482- return createHash ( "sha256" ) . update ( readFileSync ( filePath ) ) . digest ( "hex" ) ;
483- } catch {
484- return null ;
485- }
486- }
479+ // ---------------------------------------------------------------------------
480+ // Credential sanitization
481+ // ---------------------------------------------------------------------------
487482
488483/**
489484 * Basenames that MUST NOT be copied into snapshot bundles.
@@ -492,6 +487,77 @@ function computeFileDigest(filePath: string): string | null {
492487 */
493488const CREDENTIAL_SENSITIVE_BASENAMES = new Set ( [ "auth-profiles.json" ] ) ;
494489
490+ /**
491+ * Credential field names that MUST be stripped from config files
492+ * before they enter the sandbox. Credentials should be injected
493+ * at runtime via OpenShell's provider credential mechanism.
494+ */
495+ const CREDENTIAL_FIELDS = new Set ( [
496+ "apiKey" ,
497+ "api_key" ,
498+ "token" ,
499+ "secret" ,
500+ "password" ,
501+ "resolvedKey" ,
502+ ] ) ;
503+
504+ /**
505+ * Pattern-based detection for credential field names not covered by the
506+ * explicit set above. Matches common suffixes like accessToken, privateKey,
507+ * clientSecret, etc.
508+ */
509+ const CREDENTIAL_FIELD_PATTERN =
510+ / (?: a c c e s s | r e f r e s h | c l i e n t | b e a r e r | a u t h | a p i | p r i v a t e | p u b l i c | s i g n i n g | s e s s i o n ) (?: T o k e n | K e y | S e c r e t | P a s s w o r d ) $ / ;
511+
512+ function isCredentialField ( key : string ) : boolean {
513+ return CREDENTIAL_FIELDS . has ( key ) || CREDENTIAL_FIELD_PATTERN . test ( key ) ;
514+ }
515+
516+ /**
517+ * Recursively strip credential fields from a JSON-like object.
518+ * Returns a new object with sensitive values replaced by a placeholder.
519+ */
520+ function stripCredentials ( obj : unknown ) : unknown {
521+ if ( obj === null || obj === undefined ) return obj ;
522+ if ( typeof obj !== "object" ) return obj ;
523+ if ( Array . isArray ( obj ) ) return obj . map ( stripCredentials ) ;
524+
525+ const result : Record < string , unknown > = { } ;
526+ for ( const [ key , value ] of Object . entries ( obj as Record < string , unknown > ) ) {
527+ if ( isCredentialField ( key ) ) {
528+ result [ key ] = "[STRIPPED_BY_MIGRATION]" ;
529+ } else {
530+ result [ key ] = stripCredentials ( value ) ;
531+ }
532+ }
533+ return result ;
534+ }
535+
536+ /**
537+ * Strip credential fields from openclaw.json and remove the gateway
538+ * config section (contains auth tokens — regenerated by sandbox entrypoint).
539+ */
540+ function sanitizeConfigFile ( configPath : string ) : void {
541+ if ( ! existsSync ( configPath ) ) return ;
542+ const raw = readFileSync ( configPath , "utf-8" ) ;
543+ const parsed : unknown = JSON5 . parse ( raw ) ;
544+ if ( ! parsed || typeof parsed !== "object" || Array . isArray ( parsed ) ) return ;
545+ const config = parsed as Record < string , unknown > ;
546+ delete config [ "gateway" ] ;
547+ const sanitized = stripCredentials ( config ) as Record < string , unknown > ;
548+ writeFileSync ( configPath , JSON . stringify ( sanitized , null , 2 ) ) ;
549+ chmodSync ( configPath , 0o600 ) ;
550+ }
551+
552+ function computeFileDigest ( filePath : string ) : string {
553+ if ( ! existsSync ( filePath ) ) {
554+ throw new Error ( `Blueprint file not found: ${ filePath } ` ) ;
555+ }
556+ return createHash ( "sha256" ) . update ( readFileSync ( filePath ) ) . digest ( "hex" ) ;
557+ }
558+
559+ // ---------------------------------------------------------------------------
560+
495561function copyDirectory (
496562 sourcePath : string ,
497563 destinationPath : string ,
@@ -591,6 +657,13 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s
591657 const configPath = path . join ( preparedStateDir , "openclaw.json" ) ;
592658 writeFileSync ( configPath , JSON . stringify ( config , null , 2 ) ) ;
593659 chmodSync ( configPath , 0o600 ) ;
660+
661+ // SECURITY: Strip all credentials from the bundle before it enters the sandbox.
662+ // Credentials must be injected at runtime via OpenShell's provider credential
663+ // mechanism, not baked into the sandbox filesystem where a compromised agent
664+ // can read them.
665+ sanitizeConfigFile ( configPath ) ;
666+
594667 return preparedStateDir ;
595668}
596669
@@ -616,13 +689,14 @@ export function createSnapshotBundle(
616689 mkdirSync ( parentDir , { recursive : true } ) ;
617690 const snapshotStateDir = path . join ( parentDir , "openclaw" ) ;
618691 copyDirectory ( hostState . stateDir , snapshotStateDir , { stripCredentials : true } ) ;
692+ sanitizeConfigFile ( path . join ( snapshotStateDir , "openclaw.json" ) ) ;
619693
620694 if ( hostState . configPath && hostState . hasExternalConfig ) {
621695 const configSnapshotDir = path . join ( parentDir , "config" ) ;
622696 mkdirSync ( configSnapshotDir , { recursive : true } ) ;
623697 const configSnapshotPath = path . join ( configSnapshotDir , "openclaw.json" ) ;
624698 copyFileSync ( hostState . configPath , configSnapshotPath ) ;
625- chmodSync ( configSnapshotPath , 0o600 ) ;
699+ sanitizeConfigFile ( configSnapshotPath ) ;
626700 }
627701
628702 const externalRoots : MigrationExternalRoot [ ] = [ ] ;
@@ -647,15 +721,8 @@ export function createSnapshotBundle(
647721 warnings : hostState . warnings ,
648722 } ;
649723
650- if ( options . blueprintPath ) {
651- const digest = computeFileDigest ( options . blueprintPath ) ;
652- if ( ! digest ) {
653- throw new Error (
654- `Cannot compute blueprint digest for ${ options . blueprintPath } . ` +
655- "The file may be missing or unreadable." ,
656- ) ;
657- }
658- manifest . blueprintDigest = digest ;
724+ if ( options . blueprintPath !== undefined ) {
725+ manifest . blueprintDigest = computeFileDigest ( options . blueprintPath ) ;
659726 }
660727
661728 writeSnapshotManifest ( parentDir , manifest ) ;
@@ -783,18 +850,23 @@ export function restoreSnapshotToHost(
783850 }
784851 }
785852
786- // SECURITY: Validate blueprint digest.
787- // When a blueprintDigest is present in the manifest, it MUST be a non-empty
788- // string and MUST match the current blueprint — fail closed on mismatch,
789- // empty string, or null. Snapshots without a blueprintDigest (including all
790- // legacy v2 manifests and v3 snapshots created without a blueprint) skip
791- // verification.
853+ // SECURITY: Validate blueprint digest when present in manifest
792854 if ( "blueprintDigest" in manifest ) {
793855 if ( ! manifest . blueprintDigest || typeof manifest . blueprintDigest !== "string" ) {
794856 logger . error ( "Snapshot manifest has empty or invalid blueprintDigest. Refusing to restore." ) ;
795857 return false ;
796858 }
797- const currentDigest = options ?. blueprintPath ? computeFileDigest ( options . blueprintPath ) : null ;
859+ let currentDigest : string | null = null ;
860+ try {
861+ currentDigest = options ?. blueprintPath ? computeFileDigest ( options . blueprintPath ) : null ;
862+ } catch ( err : unknown ) {
863+ logger . error (
864+ `Failed to read blueprint for digest verification: ${
865+ err instanceof Error ? err . message : String ( err )
866+ } `,
867+ ) ;
868+ return false ;
869+ }
798870 if ( ! currentDigest ) {
799871 logger . error (
800872 "Snapshot contains a blueprintDigest but no blueprint is available for verification. " +
0 commit comments