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
4 changes: 2 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:

- name: Install gosec
if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true'
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.22.1
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0

- name: Run gosec
if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true'
Expand All @@ -110,7 +110,7 @@ jobs:

- name: Install golangci-lint
if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true'
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.63.4
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.0.1

- name: Run golangci-lint
if: steps.changes.outputs.go == 'true' || steps.changes.outputs.workflow_or_policy == 'true'
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@ Wrkr runs standalone and interoperates through shared `Clyra-AI/proof` contracts

## Status

Epics 1-6 are implemented.

- Epic 1: source acquisition contracts (`init`, `scan`, source manifests, incremental diff state)
- Epic 2: deterministic detector engine (Claude/Cursor/Codex/Copilot, MCP, skills, CI/headless autonomy, dependencies, secrets, compiled actions) and YAML-backed policy evaluation (`WRKR-001`..`WRKR-015`)
- Epic 3: deterministic inventory aggregation + repo exposure summaries, identity lifecycle manifest/chain updates, ranked risk reporting, posture profiles, and posture score outputs
- Epic 4: signed proof record emission (`scan_finding`, `risk_assessment`, `approval`, lifecycle transitions), proof chain verification, and compliance evidence bundle generation
- Epic 5: CLI contract hardening (`--json`, `--quiet`, `--explain`), report PDF output, manifest generation, and posture regression baseline/drift checks
- Epic 6: deterministic remediation planning (`fix`), split auth-profile PR safeguards, and `wrkr-action` scheduled/PR runtime contracts
Wrkr is in v1 contract-hardening phase with deterministic end-to-end workflows implemented for:

- discovery and scan target modes (`init`, `scan`, diff state)
- deterministic detection, policy/profile evaluation, and ranked risk output
- identity lifecycle, manifest generation, and proof chain verification
- compliance evidence export, reporting artifacts, posture scoring, and regression baselines
- deterministic remediation planning (`fix`) and auth-profile safeguards

Coverage and contract health are enforced by:

- acceptance flow tests in `internal/acceptance/v1_acceptance_test.go`
- scenario coverage mapping in `internal/scenarios/coverage_map.json`
- CI contract lanes (`make test-contracts`, `make prepush-full`, CodeQL)

## Quick Start

Expand Down
45 changes: 44 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
# Security Policy

Report security issues privately to maintainers. Do not open public issues for active vulnerabilities.
## Private Reporting

Do not open public issues for suspected vulnerabilities.

Report privately using GitHub Security Advisories:

- <https://github.com/Clyra-AI/wrkr/security/advisories/new>

If GitHub private advisories are unavailable, open a maintainers-only channel and include "SECURITY" in the title.

## What To Include

Please include:

- affected component/file/command and version/commit
- impact summary (confidentiality, integrity, availability)
- reproduction steps with deterministic inputs
- proof-of-concept or logs (scrub secrets)
- suggested mitigations/workarounds if known

## Response Expectations

- acknowledgment: within 3 business days
- triage/update: within 7 business days after acknowledgment
- remediation target:
- critical/high severity: target fix or mitigation within 30 days
- medium/low severity: target fix in a scheduled release cycle

Timelines may shift for complex supply-chain or coordinated multi-project issues; maintainers will communicate status updates in the advisory thread.

## Supported Fix Targets

Security fixes are prioritized for:

- `main`
- latest supported release line/tag maintained by the project

Older, unsupported lines may not receive backports.

## Disclosure Coordination

- keep details private until maintainers confirm a fix or mitigation is available
- coordinate publication timing with maintainers to protect downstream users
- when disclosed, include affected versions, fixed versions, and upgrade guidance
4 changes: 2 additions & 2 deletions core/cli/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ func runEvidence(args []string, stdout io.Writer, stderr io.Writer) int {
outputDir := fs.String("output", "wrkr-evidence", "evidence output directory")
statePathFlag := fs.String("state", "", "state file path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "evidence does not accept positional arguments", exitInvalidInput)
Expand Down
4 changes: 2 additions & 2 deletions core/cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ func runExport(args []string, stdout io.Writer, stderr io.Writer) int {
format := fs.String("format", "inventory", "export format")
statePathFlag := fs.String("state", "", "state file path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if *format != "inventory" {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "unsupported export format", exitInvalidInput)
Expand Down
4 changes: 2 additions & 2 deletions core/cli/fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ func runFix(args []string, stdout io.Writer, stderr io.Writer) int {
githubAPI := fs.String("github-api", strings.TrimSpace(os.Getenv("WRKR_GITHUB_API_BASE")), "github api base url")
fixToken := fs.String("fix-token", "", "fix profile token override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "fix does not accept positional arguments", exitInvalidInput)
Expand Down
20 changes: 12 additions & 8 deletions core/cli/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func runIdentity(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 0 {
return emitError(stderr, wantsJSONOutput(args), "invalid_input", "identity subcommand is required", exitInvalidInput)
}
if isHelpFlag(args[0]) {
_, _ = fmt.Fprintln(stderr, "Usage of wrkr identity: identity <list|show|approve|review|deprecate|revoke> [flags]")
return exitSuccess
}
subcommand := args[0]
subArgs := args[1:]
switch subcommand {
Expand Down Expand Up @@ -50,8 +54,8 @@ func runIdentityList(args []string, stdout io.Writer, stderr io.Writer) int {
}
jsonOut := fs.Bool("json", false, "emit machine-readable output")
statePathFlag := fs.String("state", "", "state file path override")
if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
loaded, err := manifest.Load(manifest.ResolvePath(state.ResolvePath(*statePathFlag)))
if err != nil {
Expand Down Expand Up @@ -83,8 +87,8 @@ func runIdentityShow(args []string, stdout io.Writer, stderr io.Writer) int {
}
jsonOut := fs.Bool("json", false, "emit machine-readable output")
statePathFlag := fs.String("state", "", "state file path override")
if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
agentID := strings.TrimSpace(preID)
if agentID == "" {
Expand Down Expand Up @@ -146,8 +150,8 @@ func runIdentityApprove(args []string, stdout io.Writer, stderr io.Writer) int {
scope := fs.String("scope", "", "approval scope")
expires := fs.String("expires", "90d", "approval validity duration")
statePathFlag := fs.String("state", "", "state file path override")
if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
agentID := strings.TrimSpace(preID)
if agentID == "" {
Expand Down Expand Up @@ -185,8 +189,8 @@ func runIdentityTransition(args []string, stdout io.Writer, stderr io.Writer, st
jsonOut := fs.Bool("json", false, "emit machine-readable output")
reason := fs.String("reason", "", "transition reason")
statePathFlag := fs.String("state", "", "state file path override")
if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
agentID := strings.TrimSpace(preID)
if agentID == "" {
Expand Down
4 changes: 2 additions & 2 deletions core/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ func runInit(args []string, stdout io.Writer, stderr io.Writer) int {
fixToken := fs.String("fix-token", "", "read-write token for fix profile")
configPathFlag := fs.String("config", "", "config file path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}

configPath, err := config.ResolvePath(*configPathFlag)
Expand Down
4 changes: 2 additions & 2 deletions core/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func runLifecycle(args []string, stdout io.Writer, stderr io.Writer) int {
reportShareProfile := fs.String("share-profile", "internal", "summary share profile [internal|public]")
reportTop := fs.Int("top", 5, "number of top findings included in lifecycle summary artifact")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}

resolvedStatePath := state.ResolvePath(*statePathFlag)
Expand Down
8 changes: 6 additions & 2 deletions core/cli/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ func runManifest(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 0 {
return emitError(stderr, wantsJSONOutput(args), "invalid_input", "manifest subcommand is required", exitInvalidInput)
}
if isHelpFlag(args[0]) {
_, _ = fmt.Fprintln(stderr, "Usage of wrkr manifest: manifest <generate> [flags]")
return exitSuccess
}

switch args[0] {
case "generate":
Expand All @@ -40,8 +44,8 @@ func runManifestGenerate(args []string, stdout io.Writer, stderr io.Writer) int
statePathFlag := fs.String("state", "", "state file path override")
outputPath := fs.String("output", "", "manifest output path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "manifest generate does not accept positional arguments", exitInvalidInput)
Expand Down
12 changes: 8 additions & 4 deletions core/cli/regress.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ func runRegress(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 0 {
return emitError(stderr, wantsJSONOutput(args), "invalid_input", "regress subcommand is required", exitInvalidInput)
}
if isHelpFlag(args[0]) {
_, _ = fmt.Fprintln(stderr, "Usage of wrkr regress: regress <init|run> [flags]")
return exitSuccess
}

switch args[0] {
case "init":
Expand All @@ -42,8 +46,8 @@ func runRegressInit(args []string, stdout io.Writer, stderr io.Writer) int {
baselineScanPath := fs.String("baseline", "", "state snapshot path used to initialize baseline")
outputPath := fs.String("output", "", "baseline artifact output path")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "regress init does not accept positional arguments", exitInvalidInput)
Expand Down Expand Up @@ -95,8 +99,8 @@ func runRegressRun(args []string, stdout io.Writer, stderr io.Writer) int {
reportShareProfile := fs.String("share-profile", "internal", "summary share profile [internal|public]")
reportTop := fs.Int("top", 5, "number of top findings included in regress summary artifact")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "regress run does not accept positional arguments", exitInvalidInput)
Expand Down
4 changes: 2 additions & 2 deletions core/cli/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ func runReport(args []string, stdout io.Writer, stderr io.Writer) int {
baselinePath := fs.String("baseline", "", "optional regress baseline for drift summary")
previousStatePath := fs.String("previous-state", "", "optional previous state for risk trend delta")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "report does not accept positional arguments", exitInvalidInput)
Expand Down
21 changes: 16 additions & 5 deletions core/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,8 @@ func runRootFlags(args []string, stdout io.Writer, stderr io.Writer) int {
quiet := fs.Bool("quiet", false, "suppress non-error output")
explain := fs.Bool("explain", false, "emit human-readable rationale")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return exitSuccess
}
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if fs.NArg() != 0 {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", fmt.Sprintf("unsupported command %q", fs.Arg(0)), exitInvalidInput)
Expand Down Expand Up @@ -111,6 +108,20 @@ func runRootFlags(args []string, stdout io.Writer, stderr io.Writer) int {
return exitSuccess
}

func parseFlags(fs *flag.FlagSet, args []string, stderr io.Writer, jsonOut bool) (int, bool) {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return exitSuccess, true
}
return emitError(stderr, jsonOut, "invalid_input", err.Error(), exitInvalidInput), true
}
return 0, false
}

func isHelpFlag(arg string) bool {
return arg == "-h" || arg == "--help"
}

func emitError(stderr io.Writer, jsonOut bool, code, message string, exitCode int) int {
if jsonOut {
_ = json.NewEncoder(stderr).Encode(map[string]any{
Expand Down
36 changes: 36 additions & 0 deletions core/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,42 @@ func TestRunHelpReturnsExit0(t *testing.T) {
}
}

func TestRunSubcommandHelpReturnsExit0(t *testing.T) {
t.Parallel()

cases := []struct {
name string
args []string
}{
{name: "init", args: []string{"init", "--help"}},
{name: "scan", args: []string{"scan", "--help"}},
{name: "evidence", args: []string{"evidence", "--help"}},
{name: "report", args: []string{"report", "--help"}},
{name: "verify", args: []string{"verify", "--help"}},
{name: "fix", args: []string{"fix", "--help"}},
{name: "lifecycle", args: []string{"lifecycle", "--help"}},
{name: "regress", args: []string{"regress", "--help"}},
{name: "regress run", args: []string{"regress", "run", "--help"}},
{name: "manifest", args: []string{"manifest", "--help"}},
{name: "manifest generate", args: []string{"manifest", "generate", "--help"}},
{name: "identity", args: []string{"identity", "--help"}},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

var out bytes.Buffer
var errOut bytes.Buffer
code := Run(tc.args, &out, &errOut)
if code != 0 {
t.Fatalf("expected exit 0 for %v, got %d (stderr=%q)", tc.args, code, errOut.String())
}
})
}
}

func TestRunInvalidFlagReturnsExit6(t *testing.T) {
t.Parallel()

Expand Down
6 changes: 3 additions & 3 deletions core/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ func runScan(args []string, stdout io.Writer, stderr io.Writer) int {
reportShareProfile := fs.String("report-share-profile", string(reportcore.ShareProfileInternal), "scan summary share profile [internal|public]")
reportTop := fs.Int("report-top", 5, "number of top findings included in scan summary artifact")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}

targetMode, targetValue, cfg, err := resolveScanTarget(*repo, *orgTarget, *pathTarget, *configPathFlag)
Expand Down Expand Up @@ -394,7 +394,7 @@ func evaluatePolicies(scopes []detect.Scope, findings []source.Finding, customPo
func detectorScopes(manifestOut source.Manifest) []detect.Scope {
scopes := make([]detect.Scope, 0, len(manifestOut.Repos))
for _, repo := range manifestOut.Repos {
info, err := os.Stat(repo.Location)
info, err := os.Stat(repo.Location) // #nosec G703 -- repo locations come from deterministic source acquisition inputs for current scan scope.
if err != nil || !info.IsDir() {
continue
}
Expand Down
4 changes: 2 additions & 2 deletions core/cli/score.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ func runScore(args []string, stdout io.Writer, stderr io.Writer) int {
explain := fs.Bool("explain", false, "emit rationale details")
statePathFlag := fs.String("state", "", "state file path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if *quiet && *explain && !*jsonOut {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "--quiet and --explain cannot be used together", exitInvalidInput)
Expand Down
4 changes: 2 additions & 2 deletions core/cli/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ func runVerify(args []string, stdout io.Writer, stderr io.Writer) int {
statePathFlag := fs.String("state", "", "state file path override")
chainPathFlag := fs.String("path", "", "proof chain path override")

if err := fs.Parse(args); err != nil {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", err.Error(), exitInvalidInput)
if code, handled := parseFlags(fs, args, stderr, jsonRequested || *jsonOut); handled {
return code
}
if !*verifyChain {
return emitError(stderr, jsonRequested || *jsonOut, "invalid_input", "--chain is required", exitInvalidInput)
Expand Down
Loading