Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ reports `No changes` for each synchronized target.

| Command | Behavior |
|---|---|
| `shenron install <source>` | Install a local directory or a public HTTPS Git package. |
| `shenron install <source>` | Install a local directory or a remote Git package (public HTTPS, or SSH via `git@host:path` / `ssh://`). |
| `shenron list` | List installed packages, ordered by name. |
| `shenron update <name>` | Validate and replace an installed snapshot from a new source or ref. |
| `shenron diff <name>` | Show a package's native diff plus its permission grants and missing skills. |
Expand All @@ -96,7 +96,10 @@ Common flags:

- `--store <path>` (root, persistent) selects a custom package cache
directory (default `~/.shenron/packages`).
- `install --ref <tag-or-sha>` pins the Git revision for HTTPS sources.
- `install --ref <tag-or-sha>` pins the Git revision for HTTPS and SSH sources.
SSH sources (`git@host:path` or `ssh://…`) authenticate through your
ssh-agent and verify host keys against `~/.ssh/known_hosts`; credentials are
never read from the URL.
- `update --source <dir-or-url>` and `update --ref <tag-or-sha>` replace the
installed package's source and revision.
- `diff --target <name>` and `push --target <name>` select `claude-code`,
Expand Down Expand Up @@ -277,8 +280,12 @@ matching `^[a-z][a-z0-9-]*$`, a strict semver `version`, a non-empty
# Local directory
./shenron install ./my-package

# Public Git repository (HTTPS only, immutable tag or full commit SHA)
# Public Git repository over HTTPS (immutable tag or full commit SHA)
./shenron install https://github.com/acme/reviewers.git --ref 1.2.0

# Private/public repository over SSH (uses your ssh-agent and known_hosts)
./shenron install git@github.com:acme/reviewers.git --ref 1.2.0
./shenron install ssh://git@github.com/acme/reviewers.git --ref 1.2.0
```

The first install copies the source into a content-addressed snapshot under
Expand Down Expand Up @@ -373,9 +380,11 @@ use `push --force` deliberately.
- Skill-name validation checks kebab-case syntax, not local filesystem
availability.
- OpenCode JSON is structurally preserved, not guaranteed byte-identical.
- Package installs accept only local directories or public HTTPS Git
repositories, and HTTPS sources require an immutable tag or full commit
SHA. Branches, `HEAD`, SSH, and archive URLs are refused.
- Package installs accept local directories, public HTTPS Git repositories, or
SSH Git repositories (`git@host:path` and `ssh://`). Remote sources require an
immutable tag or full commit SHA; branches, `HEAD`, embedded credentials, and
archive URLs are refused. SSH auth is delegated to the caller's ssh-agent and
host keys are verified against `known_hosts`.

## Architecture for contributors

Expand Down
8 changes: 4 additions & 4 deletions internal/cli/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type PackageUpdateOptions struct {
Output io.Writer
}

// RunPackageInstall installs a local directory or a public HTTPS Git package.
// RunPackageInstall installs a local directory or a remote (HTTPS or SSH) Git package.
func RunPackageInstall(opts PackageInstallOptions) error {
store := packageStore(opts.Store)
installed, err := store.Install(opts.Source, opts.Ref)
Expand Down Expand Up @@ -165,14 +165,14 @@ func NewInstallCmd(store func() *shenronpackage.Store) *cobra.Command {
var ref string
cmd := &cobra.Command{
Use: "install <source>",
Short: "Install a local or public HTTPS Git package",
Short: "Install a local, public HTTPS, or SSH Git package",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return RunPackageInstall(PackageInstallOptions{Store: store(), Source: args[0], Ref: ref, Output: cmd.OutOrStdout()})
},
}
cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA (required for HTTPS sources)")
cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA (required for HTTPS and SSH sources)")
return cmd
}

Expand Down Expand Up @@ -201,7 +201,7 @@ func NewUpdateCmd(store func() *shenronpackage.Store) *cobra.Command {
return RunPackageUpdate(PackageUpdateOptions{Store: store(), Name: args[0], Source: source, Ref: ref, Output: cmd.OutOrStdout()})
},
}
cmd.Flags().StringVar(&source, "source", "", "replacement local directory or public HTTPS Git source")
cmd.Flags().StringVar(&source, "source", "", "replacement local directory or remote (HTTPS or SSH) Git source")
cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA")
return cmd
}
Expand Down
125 changes: 102 additions & 23 deletions internal/package/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,17 +287,18 @@ func (s *Store) InstallLocal(source string) (*InstalledPackage, error) {
return installed, nil
}

// Install chooses a local-directory or public HTTPS Git installation based on
// source. A ref is accepted only for Git sources.
// Install chooses a local-directory or remote Git installation based on
// source. Remote sources are public HTTPS URLs or SSH URLs (scp-like
// git@host:path or ssh://). A ref is accepted only for Git sources.
func (s *Store) Install(source, ref string) (*InstalledPackage, error) {
if isHTTPSURL(source) {
if isRemoteGitSource(source) {
return s.InstallGit(source, ref)
}
if isNonHTTPSRemote(source) {
return nil, fmt.Errorf("package Git source must be a public HTTPS URL")
return nil, fmt.Errorf("package Git source must be a public HTTPS or SSH URL")
}
if ref != "" {
return nil, fmt.Errorf("--ref is only supported for public HTTPS Git sources")
return nil, fmt.Errorf("--ref is only supported for remote Git sources")
}
return s.InstallLocal(source)
}
Expand Down Expand Up @@ -411,24 +412,27 @@ func (s *Store) UpdateLocal(name, source string) (*InstalledPackage, error) {
})
}

// Update chooses a local-directory or public HTTPS Git update based on source.
// Update chooses a local-directory or remote Git update based on source.
// Remote sources are public HTTPS URLs or SSH URLs.
func (s *Store) Update(name, source, ref string) (*InstalledPackage, error) {
if isHTTPSURL(source) {
if isRemoteGitSource(source) {
return s.UpdateGit(name, source, ref)
}
if isNonHTTPSRemote(source) {
return nil, fmt.Errorf("package Git source must be a public HTTPS URL")
return nil, fmt.Errorf("package Git source must be a public HTTPS or SSH URL")
}
if ref != "" {
return nil, fmt.Errorf("--ref is only supported for public HTTPS Git sources")
return nil, fmt.Errorf("--ref is only supported for remote Git sources")
}
return s.UpdateLocal(name, source)
}

// InstallGit installs a package from a public HTTPS Git repository. ref must
// name a tag or a full commit SHA; branches and HEAD are never selected.
// InstallGit installs a package from a public HTTPS or SSH Git repository. ref
// must name a tag or a full commit SHA; branches and HEAD are never selected.
// SSH sources authenticate through the caller's ssh-agent and verify host keys
// against the local known_hosts file; no credentials are read from the URL.
func (s *Store) InstallGit(source, ref string) (*InstalledPackage, error) {
if _, err := validateGitSource(source, ref); err != nil {
if err := validateGitSource(source, ref); err != nil {
return nil, err
}
unlock, err := s.lockIndex()
Expand Down Expand Up @@ -464,9 +468,9 @@ func (s *Store) InstallGit(source, ref string) (*InstalledPackage, error) {
}

// UpdateGit replaces an installed package with a newly fetched, validated
// snapshot from a public HTTPS Git source.
// snapshot from a public HTTPS or SSH Git source.
func (s *Store) UpdateGit(name, source, ref string) (*InstalledPackage, error) {
if _, err := validateGitSource(source, ref); err != nil {
if err := validateGitSource(source, ref); err != nil {
return nil, err
}
return s.update(name, func() (*stagedSnapshot, error) {
Expand Down Expand Up @@ -581,36 +585,111 @@ func (s *Store) stageGitSnapshot(source, ref string) (*stagedSnapshot, error) {
return &stagedSnapshot{source: source, ref: ref, revision: revision.String(), root: root, tmp: tmp, pkg: pkg, digest: digest}, nil
}

var commitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
var (
commitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
// scpLikeURL matches the SSH scp-like syntax [user@]host:path where the
// host segment carries no slash, e.g. git@github.com:acme/pkg.git.
scpLikeURL = regexp.MustCompile(`^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+:.+$`)
)

func validateGitSource(source, ref string) error {
switch {
case isHTTPSURL(source):
return validateHTTPSSource(source, ref)
case isSSHURL(source):
return validateSSHSource(source, ref)
default:
return fmt.Errorf("package Git source must be a public HTTPS or SSH URL")
}
}

func validateGitSource(source, ref string) (*url.URL, error) {
func validateHTTPSSource(source, ref string) error {
parsed, err := url.Parse(source)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return nil, fmt.Errorf("package Git source must be a public HTTPS URL")
return fmt.Errorf("package Git source must be a public HTTPS or SSH URL")
}
if parsed.User != nil {
return nil, fmt.Errorf("package Git source must not include credentials")
return fmt.Errorf("package Git source must not include credentials")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("package Git source must not include a query or fragment")
return fmt.Errorf("package Git source must not include a query or fragment")
}
if err := validateNotArchive(parsed.Path); err != nil {
return err
}
return validateImmutableRef(ref)
}

// validateSSHSource accepts scp-like (git@host:path) and ssh:// sources. The
// login user in the source is not a credential; an embedded password is. Auth
// is delegated to the caller's ssh-agent at clone time.
func validateSSHSource(source, ref string) error {
if strings.HasPrefix(source, "ssh://") {
parsed, err := url.Parse(source)
if err != nil || parsed.Host == "" {
return fmt.Errorf("package Git source must be a public HTTPS or SSH URL")
}
if parsed.User != nil {
if _, hasPassword := parsed.User.Password(); hasPassword {
return fmt.Errorf("package Git source must not include credentials")
}
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("package Git source must not include a query or fragment")
}
if err := validateNotArchive(parsed.Path); err != nil {
return err
}
return validateImmutableRef(ref)
}
lowerPath := strings.ToLower(parsed.Path)
// scp-like: [user@]host:path — no password can be embedded in this form.
_, path, _ := strings.Cut(source, ":")
if err := validateNotArchive(path); err != nil {
return err
}
return validateImmutableRef(ref)
}

func validateNotArchive(path string) error {
lowerPath := strings.ToLower(path)
for _, suffix := range []string{".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz"} {
if strings.HasSuffix(lowerPath, suffix) {
return nil, fmt.Errorf("package source must be a Git repository, not an archive")
return fmt.Errorf("package source must be a Git repository, not an archive")
}
}
return nil
}

func validateImmutableRef(ref string) error {
if ref == "" || strings.EqualFold(ref, "head") || strings.HasPrefix(ref, "refs/heads/") {
return nil, fmt.Errorf("package Git ref must be an immutable tag or full commit SHA")
return fmt.Errorf("package Git ref must be an immutable tag or full commit SHA")
}
return parsed, nil
return nil
}

func isHTTPSURL(source string) bool {
parsed, err := url.Parse(source)
return err == nil && parsed.Scheme == "https"
}

// isSSHURL reports whether source is an SSH Git source: either an ssh:// URL
// or the scp-like [user@]host:path form.
func isSSHURL(source string) bool {
if parsed, err := url.Parse(source); err == nil && parsed.Scheme == "ssh" {
return true
}
if strings.Contains(source, "://") {
return false
}
return scpLikeURL.MatchString(source)
}

// isRemoteGitSource reports whether source should be fetched over the network
// as a Git repository rather than copied from a local directory.
func isRemoteGitSource(source string) bool {
return isHTTPSURL(source) || isSSHURL(source)
}

func isNonHTTPSRemote(source string) bool {
if strings.HasPrefix(source, "git@") {
return true
Expand Down
54 changes: 50 additions & 4 deletions internal/package/package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,13 @@ func TestStoreInstallGitRejectsUnsafeSourcesAndMutableRefs(t *testing.T) {
tests := []struct {
name, source, ref, want string
}{
{"ssh", "git@github.com:acme/reviewers.git", "v1.2.3", "HTTPS"},
{"credentials", "https://token@example.com/acme/reviewers.git", "v1.2.3", "credentials"},
{"ssh-password", "ssh://git:secret@github.com/acme/reviewers.git", "v1.2.3", "credentials"},
{"archive", "https://example.com/reviewers.tar.gz", "v1.2.3", "Git repository"},
{"ssh-archive", "git@github.com:acme/reviewers.tar.gz", "v1.2.3", "Git repository"},
{"branch", "https://example.com/acme/reviewers.git", "refs/heads/main", "immutable"},
{"head", "https://example.com/acme/reviewers.git", "HEAD", "immutable"},
{"ssh-head", "git@github.com:acme/reviewers.git", "HEAD", "immutable"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -591,9 +593,53 @@ func TestStoreInstallGitRejectsUnsafeSourcesAndMutableRefs(t *testing.T) {

func TestStoreInstallRejectsNonHTTPSRemoteSources(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "cache"))
for _, source := range []string{"git@github.com:acme/reviewers.git", "ssh://github.com/acme/reviewers.git", "http://example.com/acme/reviewers.git"} {
if _, err := store.Install(source, ""); err == nil || !strings.Contains(err.Error(), "public HTTPS") {
t.Errorf("Install(%q) error = %v, want HTTPS source rejection", source, err)
for _, source := range []string{"http://example.com/acme/reviewers.git", "git://example.com/acme/reviewers.git", "ftp://example.com/acme/reviewers.git"} {
if _, err := store.Install(source, ""); err == nil || !strings.Contains(err.Error(), "public HTTPS or SSH") {
t.Errorf("Install(%q) error = %v, want HTTPS/SSH source rejection", source, err)
}
}
}

func TestGitSourceClassification(t *testing.T) {
tests := []struct {
source string
https, ssh, nonHTTPS bool
}{
{"https://github.com/acme/pkg.git", true, false, false},
{"git@github.com:acme/pkg.git", false, true, true},
{"ssh://git@github.com/acme/pkg.git", false, true, true},
{"http://example.com/acme/pkg.git", false, false, true},
{"git://example.com/acme/pkg.git", false, false, true},
{"testdata/local-package", false, false, false},
{"/abs/path/to/package", false, false, false},
{"./relative/package", false, false, false},
}
for _, tt := range tests {
if got := isHTTPSURL(tt.source); got != tt.https {
t.Errorf("isHTTPSURL(%q) = %v, want %v", tt.source, got, tt.https)
}
if got := isSSHURL(tt.source); got != tt.ssh {
t.Errorf("isSSHURL(%q) = %v, want %v", tt.source, got, tt.ssh)
}
if got := isRemoteGitSource(tt.source); got != (tt.https || tt.ssh) {
t.Errorf("isRemoteGitSource(%q) = %v, want %v", tt.source, got, tt.https || tt.ssh)
}
}
}

func TestValidateGitSourceAcceptsSSH(t *testing.T) {
sources := []string{
"git@github.com:acme/reviewers.git",
"ssh://git@github.com/acme/reviewers.git",
"ssh://git@github.com:2222/acme/reviewers.git",
}
for _, source := range sources {
if err := validateGitSource(source, "v1.2.3"); err != nil {
t.Errorf("validateGitSource(%q, tag) = %v, want nil", source, err)
}
fullSHA := "0123456789abcdef0123456789abcdef01234567"
if err := validateGitSource(source, fullSHA); err != nil {
t.Errorf("validateGitSource(%q, sha) = %v, want nil", source, err)
}
}
}
Expand Down
Loading