@@ -77,6 +77,11 @@ sandbox (/etc, /usr, /bin …). Pass --force to bypass the local check
7777 Name : "force" ,
7878 Usage : "Bypass the local sensitive-path check (still requires non-/ paths)" ,
7979 },
80+ & cli.BoolFlag {
81+ Name : "yes" ,
82+ Aliases : []string {"y" },
83+ Usage : "Install your SSH key into the sandbox without asking (required in non-interactive mode when your key isn't already there)" ,
84+ },
8085 },
8186 Action : runSync ,
8287 }
@@ -190,12 +195,12 @@ func runSync(c *cli.Context) error {
190195 user = "root"
191196 }
192197
193- // 4. Install authorized_keys + start sshd. Mirror of the SSH-shell
194- // path so sync gets the same modes/sshd setup.
195- authPath := authorizedKeysPath (user )
196- if err = client .UploadFile (c .Context , id , authPath , bytesReader (pubBytes ), int64 (len (pubBytes ))); err != nil {
197- return fmt .Errorf ("could not install your SSH key: %w" , err )
198+ // 4. Install authorized_keys (with consent) + start sshd. Mirror of
199+ // the SSH-shell path so sync gets the same modes/sshd setup.
200+ if err = ensureAuthorizedKey (c , client , id , user , ref , pubBytes , keyConsentGiven (c )); err != nil {
201+ return err
198202 }
203+ authPath := authorizedKeysPath (user )
199204 prepScript := fmt .Sprintf (`
200205set -e
201206if ! [ -x /usr/sbin/sshd ]; then
@@ -364,6 +369,120 @@ func shellQuote(s string) string {
364369 return "'" + strings .ReplaceAll (s , "'" , `'\''` ) + "'"
365370}
366371
372+ // ── authorized_keys consent ───────────────────────────────────────────
373+
374+ // ensureAuthorizedKey guarantees the local public key in pubBytes is
375+ // present in the sandbox user's authorized_keys before sync/shell rely on
376+ // SSH. It is idempotent and non-destructive:
377+ //
378+ // - if a key matching ours is already installed, it returns immediately,
379+ // touching nothing (no upload, no prompt);
380+ // - if our key is NOT there, it asks for consent — the only path that
381+ // modifies the sandbox — then APPENDS our key, preserving any keys
382+ // already present.
383+ //
384+ // Consent rules mirror `sandbox rm`:
385+ // - interactive TTY → y/N confirm
386+ // - non-interactive → requires assumeYes, else a clear error
387+ // - assumeYes (--yes/-y) skips the prompt everywhere
388+ func ensureAuthorizedKey (c * cli.Context , client * api.SandboxClient , id , user , ref string , pubBytes []byte , assumeYes bool ) error {
389+ authPath := authorizedKeysPath (user )
390+
391+ wantKey , _ , _ , _ , perr := ssh .ParseAuthorizedKey (pubBytes )
392+ if perr != nil {
393+ return fmt .Errorf ("your public key doesn't look like a valid SSH key: %w" , perr )
394+ }
395+ want := canonicalAuthKey (wantKey )
396+
397+ existing := readSandboxAuthorizedKeys (c , client , id , authPath )
398+ for _ , line := range existing {
399+ if pk , _ , _ , _ , e := ssh .ParseAuthorizedKey ([]byte (line )); e == nil && canonicalAuthKey (pk ) == want {
400+ // Already trusted — nothing to do. No overwrite, no prompt.
401+ return nil
402+ }
403+ }
404+
405+ // Our key isn't there → installing it modifies the sandbox. Gate it.
406+ if ! assumeYes {
407+ if ! terminal .IsInteractive () {
408+ return fmt .Errorf ("your SSH key isn't installed in %s yet\n \n Installing it changes the sandbox's authorized_keys. Re-run with --yes to allow it:\n createos sandbox %s --yes %s" , refLabel (ref , id ), c .Command .Name , ref )
409+ }
410+ prompt := fmt .Sprintf ("Install your SSH key (%s) into %s?" , ssh .FingerprintSHA256 (wantKey ), refLabel (ref , id ))
411+ if n := len (existing ); n > 0 {
412+ prompt += fmt .Sprintf (" It already has %d other key(s); yours is added alongside them." , n )
413+ }
414+ ok , cerr := pterm .DefaultInteractiveConfirm .
415+ WithDefaultText (prompt ).
416+ WithDefaultValue (true ).
417+ Show ()
418+ if cerr != nil {
419+ return fmt .Errorf ("could not read confirmation: %w" , cerr )
420+ }
421+ if ! ok {
422+ return errors .New ("cancelled — your SSH key was not installed, so there's no way to connect" )
423+ }
424+ }
425+
426+ // Append our key, preserving existing entries (drop blank lines).
427+ merged := make ([]string , 0 , len (existing )+ 1 )
428+ for _ , l := range existing {
429+ if t := strings .TrimSpace (l ); t != "" {
430+ merged = append (merged , t )
431+ }
432+ }
433+ merged = append (merged , strings .TrimSpace (string (pubBytes )))
434+ content := strings .Join (merged , "\n " ) + "\n "
435+ if err := client .UploadFile (c .Context , id , authPath , bytesReader ([]byte (content )), int64 (len (content ))); err != nil {
436+ return fmt .Errorf ("could not install your SSH key: %w" , err )
437+ }
438+ return nil
439+ }
440+
441+ // canonicalAuthKey reduces a public key to its "<type> <base64>" form,
442+ // dropping the trailing newline and any comment, so two keys compare equal
443+ // regardless of the comment they were uploaded with.
444+ func canonicalAuthKey (pk ssh.PublicKey ) string {
445+ return strings .TrimSpace (string (ssh .MarshalAuthorizedKey (pk )))
446+ }
447+
448+ // readSandboxAuthorizedKeys returns the current authorized_keys lines for
449+ // the sandbox user, or nil when the file is missing/unreadable (a fresh
450+ // box). Errors are swallowed: a missing file is the common, expected case
451+ // and simply means "no keys yet".
452+ func readSandboxAuthorizedKeys (c * cli.Context , client * api.SandboxClient , id , authPath string ) []string {
453+ resp , err := client .ExecSandbox (c .Context , id , api.SandboxExecReq {
454+ Cmd : "sh" ,
455+ Args : []string {"-c" , fmt .Sprintf ("cat %s 2>/dev/null || true" , shellQuote (authPath ))},
456+ })
457+ if err != nil || resp .Result .ExitCode != 0 {
458+ return nil
459+ }
460+ var out []string
461+ for _ , l := range strings .Split (resp .Result .Stdout , "\n " ) {
462+ if strings .TrimSpace (l ) != "" {
463+ out = append (out , l )
464+ }
465+ }
466+ return out
467+ }
468+
469+ // keyConsentGiven reports whether the user pre-authorized installing their
470+ // SSH key, via --yes/-y. It also scans positional args because urfave/cli
471+ // v2 stops parsing flags at the first positional, so `sync my-box --yes`
472+ // would otherwise drop the flag (same workaround as `sandbox rm`).
473+ func keyConsentGiven (c * cli.Context ) bool {
474+ if c .Bool ("yes" ) {
475+ return true
476+ }
477+ for _ , a := range c .Args ().Slice () {
478+ switch strings .TrimSpace (a ) {
479+ case "-y" , "--yes" , "-yes" :
480+ return true
481+ }
482+ }
483+ return false
484+ }
485+
367486// ── path validators ───────────────────────────────────────────────
368487
369488// sensitiveLocalDirs is the set of directory NAMES we refuse to sync
0 commit comments