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
9 changes: 8 additions & 1 deletion internal/repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin
setup.PrintSkillsStatus(term)
continue
case input == "/skills install":
setup.InstallSkillsBoth(term)
setup.InstallSkillsAll(term)
continue
case input == "/set":
handleSetCommand(input, ag, term)
Expand Down Expand Up @@ -534,6 +534,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin
if !setup.IsOrchInstalled(result.Backend) {
setup.RunOrch(result.Backend, term)
}
}
// opencode manages its config and skills directly: its consent flow
// skips the global-install prompt and WriteOpenCodeConfig runs
// unconditionally below, so its skills sync on activation regardless
// of GlobalInstall — matching the unconditional startup sync in
// main.go. cc/codex skills stay behind the explicit opt-in above.
if consent.GlobalInstall || result.Backend == "opencode" {
setup.InstallSkillsReport(result.Backend, term)
}
}
Expand Down
21 changes: 12 additions & 9 deletions internal/setup/orch.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,17 @@ func InstallSkillsReport(backend string, term *tui.Terminal) {
term.PrintSystem(fmt.Sprintf("Installed %d qmax QA skills into %s", len(res.Written), res.Dir))
}

// InstallSkillsBoth materializes the catalog into both CLI backends and reports
// each. Used by the `/skills install` command.
func InstallSkillsBoth(term *tui.Terminal) {
// InstallSkillsAll materializes the catalog into every supported CLI backend
// and reports each. Used by the `/skills install` command.
func InstallSkillsAll(term *tui.Terminal) {
InstallSkillsReport("cc", term)
InstallSkillsReport("codex", term)
InstallSkillsReport("opencode", term)
}

// PrintSkillsStatus lists the qmax QA skill catalog and shows, per skill,
// whether it is currently materialized into the Claude Code and Codex skills
// directories. Backs the `/skills` command so the catalog is visible from
// whether it is currently materialized into the Claude Code, Codex, and opencode
// skills directories. Backs the `/skills` command so the catalog is visible from
// inside qmax-code, even though the skills themselves run in the CLI backends.
func PrintSkillsStatus(term *tui.Terminal) {
home, err := os.UserHomeDir()
Expand All @@ -198,20 +199,22 @@ func PrintSkillsStatus(term *tui.Terminal) {
}
ccDir, _ := skills.SkillsDir(skills.BackendCC, home)
cxDir, _ := skills.SkillsDir(skills.BackendCodex, home)
ocDir, _ := skills.SkillsDir(skills.BackendOpenCode, home)

catalog := skills.SortedCatalog()
term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills", len(catalog)))
term.PrintSystem(fmt.Sprintf("qmax QA skills (%d) — installed in: cc = ~/.claude/skills · codex = ~/.codex/skills · oc = ~/.config/opencode/skills", len(catalog)))
for _, sk := range catalog {
cc := installMark(filepath.Join(ccDir, sk.Name, "SKILL.md"))
cx := installMark(filepath.Join(cxDir, sk.Name, "SKILL.md"))
oc := installMark(filepath.Join(ocDir, sk.Name, "SKILL.md"))
desc := sk.ShortDescription
if desc == "" {
desc = sk.Description
}
fmt.Printf(" cc:%s codex:%s %s%-22s%s %s\n", cc, cx, tui.ColorBold, sk.Name, tui.ColorReset, desc)
fmt.Printf(" cc:%s codex:%s oc:%s %s%-22s%s %s\n", cc, cx, oc, tui.ColorBold, sk.Name, tui.ColorReset, desc)
}
term.PrintSystem("Skills load inside Claude Code / Codex sessions — auto-invoked by description, or `$name` in Codex.")
term.PrintSystem("Run /skills install to (re)install them into both backends now.")
term.PrintSystem("Skills load inside Claude Code / Codex / opencode sessions — auto-invoked by description, or `$name` in Codex.")
term.PrintSystem("Run /skills install to (re)install them into all backends now.")
}

// installMark returns a check or cross depending on whether path exists.
Expand Down
17 changes: 11 additions & 6 deletions internal/skills/catalog.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
// Package skills defines the backend-neutral catalog of qmax QA skills and
// materializes them into the native skill directories of each supported CLI
// backend (Claude Code and Codex).
// backend (Claude Code, Codex, and opencode).
//
// Both Claude Code and Codex load "agent skills" from a folder containing a
// SKILL.md file with YAML frontmatter (name + description), auto-invoked when a
// user request matches the description. The two CLIs share that core format but
// diverge on the optional enrichment they understand:
// All three CLIs load "agent skills" from a folder containing a SKILL.md file
// with YAML frontmatter (name + description), auto-invoked when a user request
// matches the description. They share that core format but diverge on the
// optional enrichment they understand:
//
// - Claude Code reads an `allowed-tools:` frontmatter key to gate which tools
// the skill may call.
// - Codex ignores `allowed-tools`; it reads an optional sibling
// `agents/openai.yaml` for UI metadata, MCP dependencies, and invocation
// policy.
// - opencode recognizes only name + description in frontmatter (plus optional
// license/compatibility/metadata); it ignores `allowed-tools` and has no
// sibling config. (opencode also auto-discovers ~/.claude/skills, but
// materializing into its native ~/.config/opencode/skills keeps the catalog
// authoritative for opencode-only users.)
//
// A single Skill in this catalog is the source of truth. Materialize() emits
// the right SKILL.md (and, for Codex, openai.yaml) for whichever backend is
// being installed, so one definition stays in sync across both CLIs.
// being installed, so one definition stays in sync across all three CLIs.
package skills

import (
Expand Down
11 changes: 9 additions & 2 deletions internal/skills/materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ var skillNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
type Backend string

const (
BackendCC Backend = "cc"
BackendCodex Backend = "codex"
BackendCC Backend = "cc"
BackendCodex Backend = "codex"
BackendOpenCode Backend = "opencode"
)

// MaterializeResult reports what Materialize wrote, for display to the user.
Expand All @@ -38,6 +39,8 @@ func SkillsDir(backend Backend, home string) (string, error) {
return filepath.Join(home, ".claude", "skills"), nil
case BackendCodex:
return filepath.Join(home, ".codex", "skills"), nil
case BackendOpenCode:
return filepath.Join(home, ".config", "opencode", "skills"), nil
default:
return "", fmt.Errorf("skills: unknown backend %q", backend)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 SECURITYGROUNDED New backend constant expands attack surface for path traversal validation

Adding BackendOpenCode introduces a new path ~/.config/opencode/skills that Materialize will write to. The existing ensureWithinHome guard and mkdirStrict permissions (0600/0700) still apply, but any future changes to path validation or symlink checks must consider this new backend. The risk is low because the same security controls are reused, but the expansion increases the code surface that must remain consistent.

Remediation: Resolve the path and confirm it stays within an allowed base directory (reject .. and absolute paths); prefer an allow-list of known-safe names over user-supplied paths.

More Info
  • Threat model: An attacker could attempt to exploit a symlink or path traversal if the validation logic is later modified or bypassed for the opencode backend. The impact is local file write outside the intended directory.
  • Specific code citations: SkillsDir case for BackendOpenCode returns filepath.Join(home, ".config", "opencode", "skills"). Materialize calls ensureWithinHome and mkdirStrict.
  • Existing protections: ensureWithinHome resolves symlinks and checks that the final path is within the user's home directory. mkdirStrict sets owner-only permissions.
  • Proposed mitigation: Ensure any future changes to path validation or directory creation apply uniformly to all backends, including opencode.
  • Alternative mitigations considered: Consider adding a backend-specific validation hook, but the current uniform approach is sufficient.
  • Severity calibration: Score 2 because it's a hardening gap that widens the attack surface slightly; the existing guards are robust and the new backend follows the same pattern.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/skills/materialize.go
Line: 45

Comment:
**New backend constant expands attack surface for path traversal validation**

Adding `BackendOpenCode` introduces a new path `~/.config/opencode/skills` that `Materialize` will write to. The existing `ensureWithinHome` guard and `mkdirStrict` permissions (0600/0700) still apply, but any future changes to path validation or symlink checks must consider this new backend. The risk is low because the same security controls are reused, but the expansion increases the code surface that must remain consistent.

Threat model:
An attacker could attempt to exploit a symlink or path traversal if the validation logic is later modified or bypassed for the opencode backend. The impact is local file write outside the intended directory.

Specific code citations:
`SkillsDir` case for `BackendOpenCode` returns `filepath.Join(home, ".config", "opencode", "skills")`. `Materialize` calls `ensureWithinHome` and `mkdirStrict`.

Existing protections:
`ensureWithinHome` resolves symlinks and checks that the final path is within the user's home directory. `mkdirStrict` sets owner-only permissions.

Proposed mitigation:
Ensure any future changes to path validation or directory creation apply uniformly to all backends, including opencode.

Alternative mitigations considered:
Consider adding a backend-specific validation hook, but the current uniform approach is sufficient.

Severity calibration:
Score 2 because it's a hardening gap that widens the attack surface slightly; the existing guards are robust and the new backend follows the same pattern.

How can I resolve this? If you propose a fix, please make it concise.

}
Expand Down Expand Up @@ -169,6 +172,10 @@ func renderSkillMD(backend Backend, sk Skill) string {
b.WriteString("metadata:\n")
b.WriteString(" short-description: " + yamlScalar(sk.ShortDescription) + "\n")
}
case BackendOpenCode:
// opencode recognizes only name + description in frontmatter (plus
// optional license/compatibility/metadata); it ignores allowed-tools, so
// we emit nothing extra. The body is identical across backends.
default:
// Materialize validates the backend via SkillsDir before reaching here,
// so an unknown value is a programming error, not a runtime condition.
Expand Down
90 changes: 90 additions & 0 deletions internal/skills/materialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package skills
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
Expand Down Expand Up @@ -84,6 +85,95 @@ func TestMaterializeCodexEmitsOpenAIYAML(t *testing.T) {
}
}

func TestMaterializeOpenCode(t *testing.T) {
home := t.TempDir()
res, err := Materialize(BackendOpenCode, home)
if err != nil {
t.Fatalf("Materialize(opencode): %v", err)
}
if len(res.Written) != len(Catalog) {
t.Fatalf("wrote %d skills, want %d", len(res.Written), len(Catalog))
}

for _, sk := range Catalog {
md := filepath.Join(home, ".config", "opencode", "skills", sk.Name, "SKILL.md")
data, err := os.ReadFile(md)
if err != nil {
t.Fatalf("read %s: %v", md, err)
}
text := string(data)
if !strings.HasPrefix(text, "---\n") {
t.Errorf("%s: missing frontmatter open", sk.Name)
}
if !strings.Contains(text, "name: "+sk.Name) {
t.Errorf("%s: frontmatter missing name", sk.Name)
}
if !strings.Contains(text, "description:") {
t.Errorf("%s: frontmatter missing description", sk.Name)
}
// opencode ignores allowed-tools; the opencode render omits it so the
// frontmatter stays within opencode's recognized schema.
if strings.Contains(text, "allowed-tools:") {
t.Errorf("%s: opencode SKILL.md should not carry allowed-tools", sk.Name)
}
// opencode must NOT get Codex's openai.yaml sibling.
if _, err := os.Stat(filepath.Join(home, ".config", "opencode", "skills", sk.Name, "agents", "openai.yaml")); err == nil {
t.Errorf("%s: opencode backend should not write agents/openai.yaml", sk.Name)
}
}
}

// opencode enforces a stricter skill-name regex (^[a-z0-9]+(-[a-z0-9]+)*$ — no
// underscores). Every catalog name must satisfy it so skills load in opencode.
var opencodeNameRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)

func TestCatalogNamesAreOpenCodeCompatible(t *testing.T) {
for _, sk := range Catalog {
if !opencodeNameRe.MatchString(sk.Name) {
t.Errorf("catalog skill name %q is not opencode-compatible (underscores/segments rejected)", sk.Name)
}
}
}

// TestOpenCodeNameRegexRejectsInvalid confirms the regex itself is correct:
// opencode bans underscores, leading/trailing/double hyphens, and uppercase.
func TestOpenCodeNameRegexRejectsInvalid(t *testing.T) {
bad := []string{
"has_underscore", // underscores not allowed (qmax's own regex permits them)
"trailing-",
"-leading",
"double--dash",
"CamelCase",
"with space",
"",
}
for _, name := range bad {
if opencodeNameRe.MatchString(name) {
t.Errorf("opencodeNameRe unexpectedly accepted invalid name %q", name)
}
}
}

// TestSkillsDirRejectsUnknownBackend guards the default branch so an invalid
// backend string fails loudly instead of writing to an arbitrary path.
func TestSkillsDirRejectsUnknownBackend(t *testing.T) {
home := t.TempDir()
for _, b := range []Backend{"", "claude", "CC", "open-code", "unknown"} {
if _, err := SkillsDir(b, home); err == nil {
t.Errorf("SkillsDir(%q): expected error, got nil", b)
}
}
for _, b := range []Backend{BackendCC, BackendCodex, BackendOpenCode} {
if _, err := SkillsDir(b, home); err != nil {
t.Errorf("SkillsDir(%q): unexpected error: %v", b, err)
}
}
// And Materialize must surface the same error, not write anything.
if _, err := Materialize(Backend("bogus"), home); err == nil {
t.Fatal("Materialize(bogus): expected error, got nil")
}
}

func TestMaterializeIsIdempotent(t *testing.T) {
home := t.TempDir()
if _, err := Materialize(BackendCodex, home); err != nil {
Expand Down
6 changes: 6 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ func main() {
if _, err := agent.WriteOpenCodeConfig(appConfig, ctx, appConfig.OrchPermissionMode); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not write opencode config: %v\n", err)
}
// Skills sync every launch (idempotent). opencode has no global-install
// prompt — qmax manages its config directly — so this is unconditional,
// unlike the OrchGlobalInstall-gated cc/codex sync above.
if _, err := setup.InstallSkills("opencode"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not install opencode skills: %v\n", err)
}
cliAgent = oc
}

Expand Down
Loading