From d8e57e37a403b2f99bf1cb0d88dec9182fda23cb Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:27:16 +1000 Subject: [PATCH 01/28] feat(runner): add declarative JSON gate policy --- cmd/clawscan/main.go | 15 +- cmd/clawscan/main_test.go | 30 +- docs/scanners.md | 111 ++++- internal/profiles/clawhub/clawscan.yml | 46 ++- internal/profiles/resolver.go | 239 ++++++++++- internal/profiles/resolver_test.go | 310 +++++++++++++- internal/runner/aig_scanner.go | 3 + internal/runner/aig_scanner_test.go | 22 +- internal/runner/cisco_scanner.go | 3 + internal/runner/cisco_scanner_test.go | 22 +- internal/runner/relyable_scanner.go | 5 +- internal/runner/relyable_scanner_test.go | 22 +- internal/runner/runner.go | 314 ++++++++++++++- internal/runner/runner_test.go | 491 ++++++++++++++++++++++- internal/runner/scanner_registry_test.go | 2 +- internal/runner/snyk_scanner.go | 5 +- internal/runner/snyk_scanner_test.go | 25 +- internal/runner/socket_scanner.go | 5 +- internal/runner/socket_scanner_test.go | 22 +- internal/runner/user_defined_scanner.go | 2 +- 20 files changed, 1604 insertions(+), 90 deletions(-) diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 34a66f4..13065f9 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -341,7 +341,7 @@ func printRunSummary(w io.Writer, result runner.RunTargetsResult, outputPath str if len(summary.GateRules) > 0 { details := make([]string, 0, len(summary.GateRules)) for _, rule := range summary.GateRules { - details = append(details, fmt.Sprintf("%s exit %d -> %s", rule.Scanner, rule.ExitCode, rule.Action)) + details = append(details, gateRuleSummary(rule)) } fmt.Fprintf(w, " (%s)", strings.Join(details, ", ")) } @@ -362,6 +362,19 @@ func printRunSummary(w io.Writer, result runner.RunTargetsResult, outputPath str } } +func gateRuleSummary(rule runner.FiredGateRule) string { + if rule.ExitCode != nil { + return fmt.Sprintf("%s exit %d -> %s", rule.Scanner, *rule.ExitCode, rule.Action) + } + if rule.Path != "" && len(rule.Value) > 0 { + return fmt.Sprintf("%s %s %s=%s -> %s", rule.Scanner, rule.Rule, rule.Path, rule.Value, rule.Action) + } + if rule.Path != "" { + return fmt.Sprintf("%s %s %s -> %s", rule.Scanner, rule.Rule, rule.Path, rule.Action) + } + return fmt.Sprintf("%s %s -> %s", rule.Scanner, rule.Rule, rule.Action) +} + func printBenchmarkSummary(w io.Writer, artifact runner.BenchmarkArtifact, outputPath string) { fmt.Fprintf(w, "benchmark: %s\n", artifact.Benchmark.ID) fmt.Fprintf(w, "split: %s\n", artifact.Benchmark.Split) diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 9902ab9..635aa27 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -221,7 +221,11 @@ profiles: "profiles:", "clawhub:", "clawhub-aig:", - "- skillspector", + "- id: skillspector", + "id: do-not-install", + "- risk_assessment.recommendation", + "equals: DO_NOT_INSTALL", + "normalize: identifier", "- aig", } { if !strings.Contains(stdout, want) { @@ -515,10 +519,11 @@ func TestRunCommandWritesDefaultOutputAndPrintsKeyValueSummary(t *testing.T) { } func TestPrintRunSummaryIncludesGateVerdictAndFiredRule(t *testing.T) { + exitCode := 3 artifact := runner.Artifact{ Gate: "block", GateRules: []runner.FiredGateRule{ - {Scanner: "my-scanner", Rule: "blockOnExitCode", ExitCode: 3, Action: "block"}, + {Scanner: "my-scanner", Rule: "blockOnExitCode", ExitCode: &exitCode, Action: "block"}, }, Scanners: map[string]runner.ScannerResult{}, } @@ -529,6 +534,27 @@ func TestPrintRunSummaryIncludesGateVerdictAndFiredRule(t *testing.T) { } } +func TestPrintRunSummaryIncludesDeclarativeJSONGateRule(t *testing.T) { + artifact := runner.Artifact{ + Gate: "warn", + GateRules: []runner.FiredGateRule{ + { + Scanner: "skillspector", + Rule: "high-finding", + Path: "filtered_findings[].severity", + Value: json.RawMessage(`"HIGH"`), + Action: "warn", + }, + }, + Scanners: map[string]runner.ScannerResult{}, + } + var output strings.Builder + printRunSummary(&output, runner.RunTargetsResult{Single: &artifact}, "") + if !strings.Contains(output.String(), `gate: warn (skillspector high-finding filtered_findings[].severity="HIGH" -> warn)`) { + t.Fatalf("summary missing declarative JSON gate rule:\n%s", output.String()) + } +} + func TestPrintRunSummaryKeepsBlockAcrossBatchOrder(t *testing.T) { for _, runs := range [][]runner.Artifact{ {{Gate: "warn", Scanners: map[string]runner.ScannerResult{}}, {Gate: "block", Scanners: map[string]runner.ScannerResult{}}}, diff --git a/docs/scanners.md b/docs/scanners.md index eef21b8..79a47bc 100644 --- a/docs/scanners.md +++ b/docs/scanners.md @@ -17,7 +17,7 @@ clawscan scanners clawscan scanners skillspector ``` -## User-defined scanners +## Profile scanner configuration A trusted config can mix built-in scanner IDs with user-defined command scanners. The config schema uses the existing `profiles..scanners` list: @@ -28,7 +28,24 @@ version: 1 profiles: review: scanners: - - clawscan-static + - id: skillspector + gate: + rules: + - id: do-not-install + path: risk_assessment.recommendation + equals: DO_NOT_INSTALL + action: block + - id: critical-finding + path: filtered_findings[].severity + equals: CRITICAL + action: block + - id: clawscan-static + gate: + rules: + - id: any-finding + path: findings[] + exists: true + action: warn - id: my-scanner command: my-scanner --json {{target}} env: @@ -39,11 +56,21 @@ profiles: - skill - plugin gate: + rules: + - id: high-risk + path: result.risk + equals: high + action: warn blockOnExitCode: nonzero ``` -String entries select built-in scanners. Object entries define a scanner for -that config-backed run and accept these fields: +String entries select built-in scanners without gate policy. An object with a +registered built-in `id` and no `command` selects that built-in and can attach +gate rules. The rules inspect its existing JSON output; the scanner does not +need to implement ClawScan-specific policy or change its exit codes. + +An object with a `command` defines a user-provided scanner for that +config-backed run. The same JSON rules work for built-in and command scanners: | Field | Required | Meaning | | --- | --- | --- | @@ -52,7 +79,58 @@ that config-backed run and accept these fields: | `env` | no | Required non-secret environment variable names passed to the scanner. Their values are not automatically redacted from scanner error text. | | `secretEnv` | no | Required secret environment variable names passed to the scanner. Their values are redacted from scanner error text regardless of how the names are spelled. | | `targets` | no | Supported target kinds: `skill`, `plugin`, and/or `url`. Defaults to `skill` and `url`. | -| `gate` | no | Exit-code policy with optional `blockOnExitCode` and `warnOnExitCode` rules. | +| `gate` | no | JSON and/or exit-code policy applied after the scanner completes. | + +### JSON gate rules + +`gate.rules` evaluates the scanner's raw JSON without rewriting it. Every rule +has: + +| Field | Required | Meaning | +| --- | --- | --- | +| `id` | yes | Stable name reported in `gateRules`; IDs must be unique within one scanner gate. | +| `path` | yes | One path or an ordered list of aliases. Paths use dotted object fields, with `[]` after a field to traverse every array item. Separate alternative field names with the pipe character. Array indexes and other expression syntax are not supported. | +| `action` | yes | `warn` or `block`. | +| `equals` | one condition | Matches a string, number, or boolean exactly. String comparison is case-sensitive. | +| `exists` | one condition | Must be `true`; matches when the path resolves to at least one value. | +| `normalize` | no | `identifier` makes string `equals` matching case-insensitive and treats spaces and hyphens like underscores. It cannot be used with numbers, booleans, or `exists`. | +| `fallback` | no | `root` makes the first path whose root field exists authoritative, even when a nested field is missing. Use this when a preferred filtered collection must override legacy raw collections. | + +Specify exactly one of `equals` or `exists: true`. A rule fires at most once, +even when several paths or array items match. The fired artifact records the +path that matched. A path list is an ordered fallback: ClawScan evaluates the +first path with a non-empty value and ignores later aliases, whether or not its +value matches. Empty strings and `null` fall through to the next alias. An +explicitly empty traversed array remains authoritative, letting a preferred +filtered result override legacy raw-result fields. Missing paths, empty arrays, +and type mismatches do not fire. Within one path segment, `|` alternatives are +resolved separately for each object, using the first present field; if that +field is empty, the rule can still fall through to the next path. For +`fallback: root`, a present root field prevents later path aliases from being +consulted. For example, `findings[].severity|risk_severity` checks `severity` +and then `risk_severity` on each finding. An immutable third-party scanner can +be gated without a wrapper: + +```yaml +scanners: + - id: third-party + command: third-party scan --json {{target}} + gate: + rules: + - id: critical-risk + path: + - result.risk + - result.risk_level + equals: critical + normalize: identifier + action: block + - id: any-policy-violation + path: result.violations[] + exists: true + action: warn +``` + +### Exit-code rules Each exit-code rule accepts one integer from 0 through 124, a list such as `[1, 2, 3]`, or the string `nonzero`. The block and warning rules may not @@ -66,22 +144,27 @@ gate: warnOnExitCode: 1 ``` +JSON and exit-code rules can be combined on the same scanner. A gate-eligible +process exit code is preserved alongside raw JSON, and all fired rules +participate in the same strongest-action decision. + After every selected scanner finishes, ClawScan records the strongest fired action as the top-level artifact `gate`: `block` wins over `warn`, and an artifact with no fired rules records `"gate": "pass"`. Each fired rule is also -listed in `gateRules` with its scanner ID, rule name, exit code, and action. -Gate actions are record-only: `block` does not stop later scanners or the -judge, and it does not change ClawScan's process exit status. For enforcement, -inspect `gate` and `gateRules` on a single run, `runs[].gate` and +listed in `gateRules` with its scanner ID, rule ID, and action. Exit-code rules +include `exitCode`; JSON rules include `path` and, for `equals`, the matched +`value`. Gate actions are record-only: `block` does not stop later scanners or +the judge, and it does not change ClawScan's process exit status. For +enforcement, inspect `gate` and `gateRules` on a single run, `runs[].gate` and `runs[].gateRules` in a batch, or `cases[].run.gate` and `cases[].run.gateRules` in a benchmark. The human scan summary aggregates the strongest batch action; the benchmark summary does not aggregate gate actions. -Skipped scanners do not fire gate rules. A scanner result with status `failed` -also does not fire an exit-code rule; a nonzero command that still returned -valid JSON has status `completed` and can fire one. Valid JSON from a timeout, -signal, or reserved infrastructure exit is still preserved, but its omitted -`exitCode` means it cannot fire a gate rule. +Skipped or failed scanners do not fire gate rules. A nonzero command that still +returned valid JSON has status `completed` and can fire both JSON and exit-code +rules. Valid JSON from a timeout, signal, or reserved infrastructure exit is +still preserved, but the failed scanner does not fire gate rules and its +`exitCode` is omitted. The command must write JSON to stdout. ClawScan preserves valid stdout as the scanner's raw evidence; empty or non-JSON stdout produces a failed scanner diff --git a/internal/profiles/clawhub/clawscan.yml b/internal/profiles/clawhub/clawscan.yml index 23f1d07..14155b0 100644 --- a/internal/profiles/clawhub/clawscan.yml +++ b/internal/profiles/clawhub/clawscan.yml @@ -3,8 +3,46 @@ version: 1 profiles: clawhub: scanners: - - skillspector - - clawscan-static + - id: skillspector + gate: + rules: &skillspector-gate-rules + - id: do-not-install + path: + - risk_recommendation|riskRecommendation|recommendation + - risk_assessment.recommendation|risk_recommendation|riskRecommendation + - riskAssessment.recommendation|risk_recommendation|riskRecommendation + equals: DO_NOT_INSTALL + normalize: identifier + action: block + - id: critical-finding + path: + - filtered_findings[].severity|risk_severity|level + - filteredFindings[].severity|risk_severity|level + - findings[].severity|risk_severity|level + - issues[].severity|risk_severity|level + - vulnerabilities[].severity|risk_severity|level + equals: CRITICAL + normalize: identifier + fallback: root + action: block + - id: high-finding + path: + - filtered_findings[].severity|risk_severity|level + - filteredFindings[].severity|risk_severity|level + - findings[].severity|risk_severity|level + - issues[].severity|risk_severity|level + - vulnerabilities[].severity|risk_severity|level + equals: HIGH + normalize: identifier + fallback: root + action: warn + - id: clawscan-static + gate: + rules: + - id: any-finding + path: findings[] + exists: true + action: warn sandbox: env: - OPENAI_API_KEY @@ -29,7 +67,9 @@ profiles: - < {{ prompt:prompt.md }} clawhub-aig: scanners: - - skillspector + - id: skillspector + gate: + rules: *skillspector-gate-rules - aig sandbox: env: diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index 49b6512..dc5ff66 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -3,6 +3,7 @@ package profiles import ( "bytes" "embed" + "encoding/json" "errors" "fmt" "os" @@ -23,6 +24,8 @@ var builtinProfileConfigPaths = []string{ "clawhub/clawscan.yml", } +var jsonIntegerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) + type Config struct { Version int `yaml:"version"` Sandbox *Sandbox `yaml:"sandbox,omitempty"` @@ -50,11 +53,26 @@ type ProfileScanner struct { Targets []string Gate *ProfileScannerGate custom bool + mapping bool } type ProfileScannerGate struct { - BlockOnExitCode *profileExitCodeRule `yaml:"blockOnExitCode,omitempty"` - WarnOnExitCode *profileExitCodeRule `yaml:"warnOnExitCode,omitempty"` + BlockOnExitCode *profileExitCodeRule `yaml:"blockOnExitCode,omitempty"` + WarnOnExitCode *profileExitCodeRule `yaml:"warnOnExitCode,omitempty"` + Rules []profileJSONGateRule `yaml:"rules,omitempty"` +} + +type profileJSONGateRule struct { + ID string `yaml:"id"` + Paths []string `yaml:"-"` + Equals *yaml.Node `yaml:"equals,omitempty"` + Exists bool `yaml:"exists,omitempty"` + Normalize string `yaml:"normalize,omitempty"` + Fallback string `yaml:"fallback,omitempty"` + Action string `yaml:"action"` + equalsSet bool + existsSet bool + equalsJSON json.RawMessage } type profileExitCodeRule struct { @@ -114,17 +132,181 @@ func (rule profileExitCodeRule) MarshalYAML() (interface{}, error) { } } +func (rule *profileJSONGateRule) UnmarshalYAML(node *yaml.Node) error { + node = resolvedYAMLNode(node) + if node.Kind != yaml.MappingNode { + return errors.New("JSON gate rule must be an object") + } + seenFields := make(map[string]bool, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key := node.Content[index].Value + if seenFields[key] { + return fmt.Errorf("JSON gate rule %s has duplicate field %s", rule.ID, key) + } + seenFields[key] = true + value := resolvedYAMLNode(node.Content[index+1]) + switch key { + case "id": + if err := value.Decode(&rule.ID); err != nil { + return err + } + case "path": + switch value.Kind { + case yaml.ScalarNode: + if value.Tag != "!!str" { + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + rule.Paths = []string{value.Value} + case yaml.SequenceNode: + if len(value.Content) == 0 { + return fmt.Errorf("JSON gate rule %s path list must not be empty", rule.ID) + } + rule.Paths = make([]string, 0, len(value.Content)) + for _, pathNode := range value.Content { + pathNode = resolvedYAMLNode(pathNode) + if pathNode.Kind != yaml.ScalarNode || pathNode.Tag != "!!str" { + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + rule.Paths = append(rule.Paths, pathNode.Value) + } + default: + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + case "action": + if err := value.Decode(&rule.Action); err != nil { + return err + } + case "equals": + if value.Kind != yaml.ScalarNode || value.Tag == "!!null" { + return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) + } + switch value.Tag { + case "!!str": + rule.equalsJSON, _ = json.Marshal(value.Value) + case "!!bool": + var parsed bool + if err := value.Decode(&parsed); err != nil { + return fmt.Errorf("JSON gate rule %s equals must be a boolean", rule.ID) + } + rule.equalsJSON, _ = json.Marshal(parsed) + case "!!int": + if !validJSONGateNumber(value.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) + } + if !jsonIntegerPattern.MatchString(value.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a JSON integer", rule.ID) + } + rule.equalsJSON = append(json.RawMessage(nil), value.Value...) + case "!!float": + if !validJSONGateNumber(value.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) + } + rule.equalsJSON = append(json.RawMessage(nil), value.Value...) + default: + return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) + } + rule.Equals = value + rule.equalsSet = true + case "exists": + if value.Kind != yaml.ScalarNode || value.Tag != "!!bool" { + return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) + } + rule.existsSet = true + if err := value.Decode(&rule.Exists); err != nil { + return err + } + case "normalize": + if err := value.Decode(&rule.Normalize); err != nil { + return err + } + case "fallback": + if err := value.Decode(&rule.Fallback); err != nil { + return err + } + default: + return fmt.Errorf("field %s not found in type profiles.profileJSONGateRule", key) + } + } + if strings.TrimSpace(rule.ID) == "" { + return errors.New("JSON gate rule id must not be empty") + } + if len(rule.Paths) == 0 { + return fmt.Errorf("JSON gate rule %s path must not be empty", rule.ID) + } + seenPaths := map[string]bool{} + for _, path := range rule.Paths { + if err := runner.ValidateJSONGatePath(path); err != nil { + return fmt.Errorf("JSON gate rule %s path %q is invalid: %w", rule.ID, path, err) + } + if seenPaths[path] { + return fmt.Errorf("JSON gate rule %s has duplicate path %q", rule.ID, path) + } + seenPaths[path] = true + } + if rule.Action != "warn" && rule.Action != "block" { + return fmt.Errorf("JSON gate rule %s action must be warn or block", rule.ID) + } + if rule.existsSet && !rule.Exists { + return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) + } + if rule.equalsSet == rule.existsSet { + return fmt.Errorf("JSON gate rule %s must include exactly one of equals or exists: true", rule.ID) + } + if rule.Normalize != "" && rule.Normalize != "identifier" { + return fmt.Errorf("JSON gate rule %s normalize must be identifier", rule.ID) + } + if rule.Normalize != "" && (!rule.equalsSet || rule.Equals.Tag != "!!str") { + return fmt.Errorf("JSON gate rule %s normalize requires a string equals value", rule.ID) + } + if rule.Fallback != "" && rule.Fallback != "root" { + return fmt.Errorf("JSON gate rule %s fallback must be root", rule.ID) + } + return nil +} + +func validJSONGateNumber(value string) bool { + if !json.Valid([]byte(value)) { + return false + } + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.UseNumber() + var parsed any + if err := decoder.Decode(&parsed); err != nil { + return false + } + _, ok := parsed.(json.Number) + return ok +} + +func (rule profileJSONGateRule) MarshalYAML() (interface{}, error) { + var path any + if len(rule.Paths) == 1 { + path = rule.Paths[0] + } else { + path = append([]string(nil), rule.Paths...) + } + return struct { + ID string `yaml:"id"` + Path any `yaml:"path"` + Equals *yaml.Node `yaml:"equals,omitempty"` + Exists bool `yaml:"exists,omitempty"` + Normalize string `yaml:"normalize,omitempty"` + Fallback string `yaml:"fallback,omitempty"` + Action string `yaml:"action"` + }{rule.ID, path, rule.Equals, rule.Exists, rule.Normalize, rule.Fallback, rule.Action}, nil +} + func (gate *ProfileScannerGate) UnmarshalYAML(node *yaml.Node) error { node = resolvedYAMLNode(node) if node.Kind != yaml.MappingNode { return errors.New("scanner gate must be an object") } if len(node.Content) == 0 { - return errors.New("scanner gate must include blockOnExitCode or warnOnExitCode") + return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") } for index := 0; index < len(node.Content); index += 2 { switch node.Content[index].Value { - case "blockOnExitCode", "warnOnExitCode": + case "blockOnExitCode", "warnOnExitCode", "rules": value := resolvedYAMLNode(node.Content[index+1]) if value.Tag == "!!null" { return fmt.Errorf("scanner gate %s must not be null", node.Content[index].Value) @@ -134,7 +316,23 @@ func (gate *ProfileScannerGate) UnmarshalYAML(node *yaml.Node) error { } } type plainGate ProfileScannerGate - return node.Decode((*plainGate)(gate)) + if err := node.Decode((*plainGate)(gate)); err != nil { + return err + } + if gate.Rules != nil && len(gate.Rules) == 0 { + return errors.New("scanner gate rules must not be empty") + } + seenRuleIDs := map[string]bool{} + for _, rule := range gate.Rules { + if seenRuleIDs[rule.ID] { + return fmt.Errorf("duplicate JSON gate rule id %s", rule.ID) + } + seenRuleIDs[rule.ID] = true + } + if gate.BlockOnExitCode == nil && gate.WarnOnExitCode == nil && len(gate.Rules) == 0 { + return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") + } + return nil } func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error { @@ -148,6 +346,9 @@ func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error { for index := 0; index < len(node.Content); index += 2 { switch node.Content[index].Value { case "id", "command", "env", "secretEnv", "targets", "gate": + if node.Content[index].Value == "command" { + scanner.custom = true + } if node.Content[index].Value == "gate" { gateNode := resolvedYAMLNode(node.Content[index+1]) if gateNode.Kind != yaml.MappingNode { @@ -175,7 +376,7 @@ func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error { scanner.SecretEnv = value.SecretEnv scanner.Targets = value.Targets scanner.Gate = value.Gate - scanner.custom = true + scanner.mapping = true return nil default: return fmt.Errorf("scanner entry must be a string or object") @@ -190,12 +391,12 @@ func resolvedYAMLNode(node *yaml.Node) *yaml.Node { } func (scanner ProfileScanner) MarshalYAML() (interface{}, error) { - if !scanner.custom { + if !scanner.mapping && !scanner.custom { return scanner.ID, nil } return struct { ID string `yaml:"id"` - Command string `yaml:"command"` + Command string `yaml:"command,omitempty"` Env []string `yaml:"env,omitempty"` SecretEnv []string `yaml:"secretEnv,omitempty"` Targets []string `yaml:"targets,omitempty"` @@ -254,7 +455,13 @@ func profileGateRules(scanners []ProfileScanner, selectedScannerIDs []string) ma Codes: append([]int(nil), scanner.Gate.WarnOnExitCode.Codes...), Nonzero: scanner.Gate.WarnOnExitCode.Nonzero, } } - if policy.BlockOnExitCode == nil && policy.WarnOnExitCode == nil { + for _, rule := range scanner.Gate.Rules { + policy.JSONRules = append(policy.JSONRules, runner.JSONGateRule{ + ID: rule.ID, Paths: append([]string(nil), rule.Paths...), Equals: append(json.RawMessage(nil), rule.equalsJSON...), Exists: rule.Exists, Normalize: rule.Normalize, + Fallback: rule.Fallback, Action: rule.Action, + }) + } + if policy.BlockOnExitCode == nil && policy.WarnOnExitCode == nil && len(policy.JSONRules) == 0 { continue } rules[scanner.ID] = policy @@ -1087,6 +1294,20 @@ func invalidDeclaredEnvName(env []string) string { func validateProfile(name string, profile Profile) error { seen := map[string]bool{} for _, scanner := range profile.Scanners { + if scanner.mapping && strings.TrimSpace(scanner.ID) == "" { + if scanner.custom { + return fmt.Errorf("User-defined scanner in profile %s must include a non-empty id", name) + } + return fmt.Errorf("Scanner object in profile %s must include a non-empty id", name) + } + if scanner.mapping && !scanner.custom { + if !runner.DefaultScannerRegistry().Contains(scanner.ID) { + return fmt.Errorf("User-defined scanner %s in profile %s must include a non-empty command", scanner.ID, name) + } + if len(scanner.Env) > 0 || len(scanner.SecretEnv) > 0 || len(scanner.Targets) > 0 { + return fmt.Errorf("Built-in scanner reference %s in profile %s accepts only id and gate", scanner.ID, name) + } + } if scanner.custom && strings.TrimSpace(scanner.ID) == "" { return fmt.Errorf("User-defined scanner in profile %s must include a non-empty id", name) } diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index c9bbac5..bd66781 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -32,6 +32,12 @@ func TestResolveArgsUsesEmbeddedClawHubProfile(t *testing.T) { if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static" { t.Fatalf("scanners = %q", got) } + if got := len(opts.GateRules["skillspector"].JSONRules); got != 3 { + t.Fatalf("skillspector JSON gate rules = %#v", opts.GateRules["skillspector"].JSONRules) + } + if got := len(opts.GateRules["clawscan-static"].JSONRules); got != 1 { + t.Fatalf("static JSON gate rules = %#v", opts.GateRules["clawscan-static"].JSONRules) + } if opts.Judge == nil { t.Fatal("expected embedded clawhub judge") } @@ -79,6 +85,9 @@ func TestResolveArgsUsesEmbeddedClawHubAIGCandidateProfile(t *testing.T) { if got := strings.Join(candidate.Scanners, ","); got != "skillspector,aig" { t.Fatalf("scanners = %q", got) } + if got := len(candidate.GateRules["skillspector"].JSONRules); got != 3 { + t.Fatalf("skillspector JSON gate rules = %#v", candidate.GateRules["skillspector"].JSONRules) + } if candidate.Judge == nil || clawhub.Judge == nil { t.Fatal("missing embedded ClawHub judge") } @@ -880,6 +889,294 @@ profiles: } } +func TestResolveArgsAttachesDeclarativeJSONRulesToBuiltInScanner(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: skillspector + gate: + rules: + - id: do-not-install + path: risk_assessment.recommendation + equals: DO_NOT_INSTALL + action: block + blockOnExitCode: 1 +`) + + opts, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(opts.Scanners, ","); got != "skillspector" { + t.Fatalf("scanners = %q", got) + } + rules := opts.GateRules["skillspector"].JSONRules + if len(rules) != 1 { + t.Fatalf("JSON gate rules = %#v", rules) + } + if rule := rules[0]; rule.ID != "do-not-install" || !reflect.DeepEqual(rule.Paths, []string{"risk_assessment.recommendation"}) || + rule.Action != "block" || !bytes.Equal(rule.Equals, []byte(`"DO_NOT_INSTALL"`)) || rule.Exists { + t.Fatalf("JSON gate rule = %#v", rule) + } + if got := opts.GateRules["skillspector"].BlockOnExitCode.Codes; !reflect.DeepEqual(got, []int{1}) { + t.Fatalf("exit-code policy = %#v", opts.GateRules["skillspector"]) + } + adapter, ok := opts.ScannerRegistry.Adapter("skillspector") + if !ok || adapter.ID() != "skillspector" { + t.Fatalf("built-in scanner adapter = %#v, present = %v", adapter, ok) + } +} + +func TestResolveArgsAttachesDeclarativeJSONRulesToCommandScanner(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: third-party + command: third-party --json {{target}} + gate: + rules: + - id: critical-risk + path: result.risk + equals: critical + action: block +`) + + opts, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err != nil { + t.Fatal(err) + } + rules := opts.GateRules["third-party"].JSONRules + if len(rules) != 1 || rules[0].ID != "critical-risk" || !reflect.DeepEqual(rules[0].Paths, []string{"result.risk"}) { + t.Fatalf("JSON gate rules = %#v", rules) + } +} + +func TestResolveArgsPreservesExactJSONGateNumber(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: skillspector + gate: + rules: + - id: exact-sequence + path: result.sequence + equals: 9007199254740993.0 + action: block +`) + + opts, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err != nil { + t.Fatal(err) + } + rules := opts.GateRules["skillspector"].JSONRules + if len(rules) != 1 || !bytes.Equal(rules[0].Equals, []byte(`9007199254740993.0`)) { + t.Fatalf("JSON gate rules = %#v", rules) + } +} + +func TestEmbeddedClawHubGateCoversSupportedSkillSpectorShapes(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + path string + }{ + { + name: "top-level recommendation", + raw: json.RawMessage(`{"recommendation":"do-not-install","issues":[]}`), + want: "block", + path: "risk_recommendation|riskRecommendation|recommendation", + }, + { + name: "null recommendation alias falls through", + raw: json.RawMessage(`{"risk_recommendation":null,"recommendation":"DO_NOT_INSTALL","issues":[]}`), + want: "block", + path: "risk_recommendation|riskRecommendation|recommendation", + }, + { + name: "nested recommendation behind empty top-level alias", + raw: json.RawMessage(`{"recommendation":"","risk_assessment":{"recommendation":"DO_NOT_INSTALL"},"issues":[]}`), + want: "block", + path: "risk_assessment.recommendation|risk_recommendation|riskRecommendation", + }, + { + name: "risk recommendation has precedence", + raw: json.RawMessage(`{"recommendation":"SAFE","risk_recommendation":"DO_NOT_INSTALL","issues":[]}`), + want: "block", + path: "risk_recommendation|riskRecommendation|recommendation", + }, + { + name: "empty preferred recommendation group falls back to nested", + raw: json.RawMessage(`{"risk_recommendation":"","recommendation":"SAFE","risk_assessment":{"recommendation":"DO_NOT_INSTALL"},"issues":[]}`), + want: "block", + path: "risk_assessment.recommendation|risk_recommendation|riskRecommendation", + }, + { + name: "camel-case report", + raw: json.RawMessage(`{"riskAssessment":{"recommendation":"CAUTION"},"filteredFindings":[{"severity":"CRITICAL"}]}`), + want: "block", + path: "filteredFindings[].severity|risk_severity|level", + }, + { + name: "alternate issue fields", + raw: json.RawMessage(`{"findings":[{"level":"high"}]}`), + want: "warn", + path: "findings[].severity|risk_severity|level", + }, + { + name: "empty filtered findings override raw findings", + raw: json.RawMessage(`{"filtered_findings":[],"findings":[{"severity":"CRITICAL"}]}`), + want: "pass", + }, + { + name: "null filtered findings fall through to raw findings", + raw: json.RawMessage(`{"filtered_findings":null,"findings":[{"severity":"CRITICAL"}]}`), + want: "block", + path: "findings[].severity|risk_severity|level", + }, + { + name: "nonmatching filtered findings override raw findings", + raw: json.RawMessage(`{"filtered_findings":[{"severity":"LOW"}],"findings":[{"severity":"CRITICAL"}]}`), + want: "pass", + }, + { + name: "finding field aliases resolve per item", + raw: json.RawMessage(`{"filtered_findings":[{"severity":"LOW"},{"risk_severity":"CRITICAL"}]}`), + want: "block", + path: "filtered_findings[].severity|risk_severity|level", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + opts, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--sandbox", "off"}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + opts.Judge = nil + artifact, err := runner.Run(opts, runner.RunContext{ + Env: map[string]string{}, + ScannerRunner: profileScannerResultRunner{results: map[string]runner.ScannerResult{ + "skillspector": {Status: "completed", Raw: test.raw}, + "clawscan-static": {Status: "completed", Raw: json.RawMessage(`{"findings":[]}`)}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != test.want { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + if test.path == "" && len(artifact.GateRules) != 0 { + t.Fatalf("gate rules = %#v", artifact.GateRules) + } + if test.path != "" && (len(artifact.GateRules) != 1 || artifact.GateRules[0].Path != test.path) { + t.Fatalf("gate rules = %#v", artifact.GateRules) + } + }) + } +} + +func TestCommandScannerDeclarativeJSONRuleGatesUnchangedOutput(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + writeFile(t, filepath.Join(target, "SKILL.md"), "# Demo\n") + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: third-party + command: third-party --json {{target}} + gate: + rules: + - id: critical-risk + path: result.risk + equals: critical + action: block +`) + + opts, err := ResolveArgs([]string{target, "--config", config, "--profile", "review", "--sandbox", "off"}, dir) + if err != nil { + t.Fatal(err) + } + raw := `{"result":{"risk":"critical"},"scanner":"third-party"}` + artifact, err := runner.Run(opts, runner.RunContext{ + Env: map[string]string{}, CommandRunner: &profileCommandRunner{stdout: raw}, + }) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + rule := artifact.GateRules[0] + if rule.Scanner != "third-party" || rule.Rule != "critical-risk" || rule.Path != "result.risk" || + !bytes.Equal(rule.Value, []byte(`"critical"`)) || rule.Action != "block" { + t.Fatalf("gate rule = %#v", rule) + } + if got := string(artifact.Scanners["third-party"].Raw); got != raw { + t.Fatalf("raw scanner output changed\nwant %s\ngot %s", raw, got) + } +} + +func TestResolveArgsRejectsInvalidDeclarativeJSONGateRules(t *testing.T) { + tests := []struct { + name string + rules string + want string + }{ + {name: "empty rules", rules: "[]", want: "scanner gate rules must not be empty"}, + {name: "null rules", rules: "null", want: "scanner gate rules must not be null"}, + {name: "missing id", rules: "[{path: result.risk, equals: critical, action: block}]", want: "JSON gate rule id must not be empty"}, + {name: "missing path", rules: "[{id: critical-risk, equals: critical, action: block}]", want: "JSON gate rule critical-risk path must not be empty"}, + {name: "empty path list", rules: "[{id: critical-risk, path: [], equals: critical, action: block}]", want: "JSON gate rule critical-risk path list must not be empty"}, + {name: "boolean path", rules: "[{id: critical-risk, path: true, equals: critical, action: block}]", want: "JSON gate rule critical-risk path must be a string or list of strings"}, + {name: "number in path list", rules: "[{id: critical-risk, path: [result.risk, 7], equals: critical, action: block}]", want: "JSON gate rule critical-risk path must be a string or list of strings"}, + {name: "invalid path", rules: `[{id: critical-risk, path: "findings[0].severity", equals: critical, action: block}]`, want: "JSON gate rule critical-risk path"}, + {name: "duplicate path", rules: "[{id: critical-risk, path: [result.risk, result.risk], equals: critical, action: block}]", want: "JSON gate rule critical-risk has duplicate path"}, + {name: "missing action", rules: "[{id: critical-risk, path: result.risk, equals: critical}]", want: "JSON gate rule critical-risk action must be warn or block"}, + {name: "invalid action", rules: "[{id: critical-risk, path: result.risk, equals: critical, action: pass}]", want: "JSON gate rule critical-risk action must be warn or block"}, + {name: "missing predicate", rules: "[{id: critical-risk, path: result.risk, action: block}]", want: "JSON gate rule critical-risk must include exactly one of equals or exists: true"}, + {name: "two predicates", rules: "[{id: critical-risk, path: result.risk, equals: critical, exists: true, action: block}]", want: "JSON gate rule critical-risk must include exactly one of equals or exists: true"}, + {name: "false exists", rules: "[{id: critical-risk, path: result.risk, exists: false, action: block}]", want: "JSON gate rule critical-risk exists must be true"}, + {name: "string exists", rules: `[{id: critical-risk, path: result.risk, exists: "true", action: block}]`, want: "JSON gate rule critical-risk exists must be true"}, + {name: "false exists with equals", rules: "[{id: critical-risk, path: result.risk, equals: critical, exists: false, action: block}]", want: "JSON gate rule critical-risk exists must be true"}, + {name: "null equals", rules: "[{id: critical-risk, path: result.risk, equals: null, action: block}]", want: "JSON gate rule critical-risk equals must be a string, number, or boolean"}, + {name: "object equals", rules: "[{id: critical-risk, path: result.risk, equals: {severity: critical}, action: block}]", want: "JSON gate rule critical-risk equals must be a string, number, or boolean"}, + {name: "invalid tagged boolean", rules: "[{id: critical-risk, path: result.risk, equals: !!bool nope, action: block}]", want: "JSON gate rule critical-risk equals must be a boolean"}, + {name: "boolean tagged as number", rules: "[{id: critical-risk, path: result.risk, equals: !!float true, action: block}]", want: "JSON gate rule critical-risk equals must be a finite JSON number"}, + {name: "float tagged as integer", rules: "[{id: critical-risk, path: result.risk, equals: !!int 1.5, action: block}]", want: "JSON gate rule critical-risk equals must be a JSON integer"}, + {name: "non-finite number", rules: "[{id: critical-risk, path: result.risk, equals: .nan, action: block}]", want: "JSON gate rule critical-risk equals must be a finite JSON number"}, + {name: "invalid normalize", rules: "[{id: critical-risk, path: result.risk, equals: critical, normalize: lowercase, action: block}]", want: "JSON gate rule critical-risk normalize must be identifier"}, + {name: "normalize number", rules: "[{id: critical-risk, path: result.risk, equals: 7, normalize: identifier, action: block}]", want: "JSON gate rule critical-risk normalize requires a string equals value"}, + {name: "normalize exists", rules: "[{id: critical-risk, path: result.risk, exists: true, normalize: identifier, action: block}]", want: "JSON gate rule critical-risk normalize requires a string equals value"}, + {name: "invalid fallback", rules: "[{id: critical-risk, path: result.risk, equals: critical, fallback: value, action: block}]", want: "JSON gate rule critical-risk fallback must be root"}, + {name: "unknown field", rules: "[{id: critical-risk, path: result.risk, equals: critical, action: block, message: nope}]", want: "field message not found"}, + {name: "duplicate field", rules: "[{id: critical-risk, path: result.risk, equals: high, equals: critical, action: block}]", want: "JSON gate rule critical-risk has duplicate field equals"}, + {name: "duplicate id", rules: "[{id: risk, path: result.risk, equals: high, action: warn}, {id: risk, path: result.risk, equals: critical, action: block}]", want: "duplicate JSON gate rule id risk"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, "version: 1\nprofiles:\n review:\n scanners:\n - id: demo\n command: demo {{target}}\n gate:\n rules: "+test.rules+"\n") + _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("err = %v", err) + } + }) + } +} + func TestResolveArgsAcceptsSingleExitCodeGateRule(t *testing.T) { dir := t.TempDir() config := filepath.Join(dir, ".clawscan.yml") @@ -934,7 +1231,7 @@ func TestResolveArgsRejectsInvalidExitCodeGateRules(t *testing.T) { {name: "non integer", rules: "blockOnExitCode: nope", want: `must be an integer from 0 through 124, a list of those integers, or "nonzero"`}, {name: "empty list", rules: "blockOnExitCode: []", want: "must not be an empty list"}, {name: "null rule", rules: "blockOnExitCode: null", want: "scanner gate blockOnExitCode must not be null"}, - {name: "empty gate", rules: "{}", want: "scanner gate must include blockOnExitCode or warnOnExitCode"}, + {name: "empty gate", rules: "{}", want: "scanner gate must include blockOnExitCode, warnOnExitCode, or rules"}, {name: "null gate", rules: "null", want: "scanner gate must be an object"}, {name: "block nonzero overlap", rules: "blockOnExitCode: nonzero\n warnOnExitCode: [0, 2]", want: "blockOnExitCode and warnOnExitCode both claim exit code 2"}, {name: "warn nonzero overlap", rules: "blockOnExitCode: [0, 2]\n warnOnExitCode: nonzero", want: "blockOnExitCode and warnOnExitCode both claim exit code 2"}, @@ -1732,6 +2029,17 @@ type profileCommandCall struct { args []string } +type profileScannerResultRunner struct { + results map[string]runner.ScannerResult +} + +func (scannerRunner profileScannerResultRunner) RunScanner(name string, _ string, startedAt string) (runner.ScannerResult, error) { + result := scannerRunner.results[name] + result.StartedAt = startedAt + result.CompletedAt = startedAt + return result, nil +} + func (commandRunner *profileCommandRunner) Run(command string, args []string, _ string, _ time.Duration) (runner.CommandOutput, error) { commandRunner.command = command commandRunner.args = append([]string(nil), args...) diff --git a/internal/runner/aig_scanner.go b/internal/runner/aig_scanner.go index 73e702c..8c2e32b 100644 --- a/internal/runner/aig_scanner.go +++ b/internal/runner/aig_scanner.go @@ -69,6 +69,7 @@ func (runner ExternalScannerRunner) runAIG(target string, startedAt string) (Sca } output, runErr := runner.CommandRunner.Run(command, args, resultDir, timeout) + exitCode := gateEligibleExitCode(output.ExitCode) raw, readErr := os.ReadFile(resultPath) finishedAt := completedAt() if readErr != nil { @@ -106,10 +107,12 @@ func (runner ExternalScannerRunner) runAIG(target string, startedAt string) (Sca CompletedAt: finishedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), } if runErr != nil { result.Error = scannerCommandError(runErr, output.Stderr, runner.Env) + result.Status = commandScannerResultStatus(output, runErr) } return result, nil } diff --git a/internal/runner/aig_scanner_test.go b/internal/runner/aig_scanner_test.go index d827cda..253a3f7 100644 --- a/internal/runner/aig_scanner_test.go +++ b/internal/runner/aig_scanner_test.go @@ -181,10 +181,12 @@ func TestAIGScannerDockerRunMountsTargetAndOutputDirectory(t *testing.T) { func TestAIGScannerCompletesNonZeroExitWithValidSARIF(t *testing.T) { target := createAIGTestSkill(t) + exitCode := 1 commandRunner := &aigRecordingCommandRunner{ - output: aigSARIF, - stderr: "findings require review", - err: errors.New("exit status 1"), + output: aigSARIF, + stderr: "findings require review", + err: errors.New("exit status 1"), + exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "aig", "--sandbox", "off"}) if err != nil { @@ -201,6 +203,9 @@ func TestAIGScannerCompletesNonZeroExitWithValidSARIF(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q error = %q", result.Status, result.Error) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if !strings.Contains(result.Error, "exit status 1") || !strings.Contains(result.Error, "findings require review") { t.Fatalf("error = %q", result.Error) } @@ -343,10 +348,11 @@ func createAIGTestSkill(t *testing.T) string { } type aigRecordingCommandRunner struct { - calls []commandCall - output string - stderr string - err error + calls []commandCall + output string + stderr string + err error + exitCode *int } func (runner *aigRecordingCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) { @@ -359,7 +365,7 @@ func (runner *aigRecordingCommandRunner) Run(command string, args []string, cwd } } } - return CommandOutput{Stderr: runner.stderr}, runner.err + return CommandOutput{Stderr: runner.stderr, ExitCode: runner.exitCode}, runner.err } type aigDockerRecordingCommandRunner struct { diff --git a/internal/runner/cisco_scanner.go b/internal/runner/cisco_scanner.go index c337cb4..b9eaed3 100644 --- a/internal/runner/cisco_scanner.go +++ b/internal/runner/cisco_scanner.go @@ -33,6 +33,7 @@ func (runner ExternalScannerRunner) runCisco(target string, startedAt string) (S cwd = resultDir } output, runErr := runner.CommandRunner.Run(command, args, cwd, timeout) + exitCode := gateEligibleExitCode(output.ExitCode) raw, readErr := os.ReadFile(resultPath) completedAt := time.Now().UTC().Format(time.RFC3339Nano) if readErr != nil { @@ -69,10 +70,12 @@ func (runner ExternalScannerRunner) runCisco(target string, startedAt string) (S CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), } if runErr != nil { result.Error = scannerCommandError(runErr, output.Stderr, runner.Env) + result.Status = commandScannerResultStatus(output, runErr) } return result, nil } diff --git a/internal/runner/cisco_scanner_test.go b/internal/runner/cisco_scanner_test.go index 51cdb9b..864ea9c 100644 --- a/internal/runner/cisco_scanner_test.go +++ b/internal/runner/cisco_scanner_test.go @@ -163,10 +163,12 @@ func TestCiscoScannerCompletesNonZeroExitWithJSONOutputFile(t *testing.T) { t.Fatal(err) } const ciscoJSON = `{"scanner":"cisco","findings":[{"id":"pipeline-risk"}]}` + exitCode := 1 runner := &ciscoRecordingCommandRunner{ - output: ciscoJSON, - stderr: "high severity findings", - err: errors.New("exit status 1"), + output: ciscoJSON, + stderr: "high severity findings", + err: errors.New("exit status 1"), + exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "cisco"}) if err != nil { @@ -183,6 +185,9 @@ func TestCiscoScannerCompletesNonZeroExitWithJSONOutputFile(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q error = %q", result.Status, result.Error) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if !strings.Contains(result.Error, "exit status 1") || !strings.Contains(result.Error, "high severity findings") { t.Fatalf("error = %q", result.Error) } @@ -279,10 +284,11 @@ func TestRunDispatchesCiscoScannerInsteadOfGenericSkipped(t *testing.T) { } type ciscoRecordingCommandRunner struct { - calls []commandCall - output string - stderr string - err error + calls []commandCall + output string + stderr string + err error + exitCode *int } func (r *ciscoRecordingCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) { @@ -295,7 +301,7 @@ func (r *ciscoRecordingCommandRunner) Run(command string, args []string, cwd str } } } - return CommandOutput{Stderr: r.stderr}, r.err + return CommandOutput{Stderr: r.stderr, ExitCode: r.exitCode}, r.err } func argValue(args []string, name string) string { diff --git a/internal/runner/relyable_scanner.go b/internal/runner/relyable_scanner.go index bf026a6..05767ea 100644 --- a/internal/runner/relyable_scanner.go +++ b/internal/runner/relyable_scanner.go @@ -33,6 +33,7 @@ func (runner ExternalScannerRunner) runRelyable(target string, startedAt string) timeout = 20 * time.Minute } output, runErr := runner.CommandRunner.Run(command, args, "", timeout) + exitCode := gateEligibleExitCode(output.ExitCode) completedAt := time.Now().UTC().Format(time.RFC3339Nano) raw := strings.TrimSpace(output.Stdout) if runErr != nil { @@ -42,11 +43,12 @@ func (runner ExternalScannerRunner) runRelyable(target string, startedAt string) } if json.Valid([]byte(raw)) { return ScannerResult{ - Status: "completed", + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -85,6 +87,7 @@ func (runner ExternalScannerRunner) runRelyable(target string, startedAt string) CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } diff --git a/internal/runner/relyable_scanner_test.go b/internal/runner/relyable_scanner_test.go index d8e0aad..cce4659 100644 --- a/internal/runner/relyable_scanner_test.go +++ b/internal/runner/relyable_scanner_test.go @@ -11,15 +11,16 @@ import ( ) type relyableRecordingCommandRunner struct { - calls []commandCall - stdout string - stderr string - err error + calls []commandCall + stdout string + stderr string + err error + exitCode *int } func (r *relyableRecordingCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) { r.calls = append(r.calls, commandCall{command: command, args: append([]string(nil), args...), cwd: cwd}) - return CommandOutput{Stdout: r.stdout, Stderr: r.stderr}, r.err + return CommandOutput{Stdout: r.stdout, Stderr: r.stderr, ExitCode: r.exitCode}, r.err } func TestRunExecutesRelyableScannerWithoutHostExecOutsideSandbox(t *testing.T) { @@ -83,11 +84,13 @@ func TestRelyableScannerPassesHostExecAckInDockerSandbox(t *testing.T) { func TestRelyableScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { const relyableJSON = `{"schemaVersion":"relyable-scan-v1","error":"no SKILL.md found in target or its immediate children"}` + exitCode := 2 runner := ExternalScannerRunner{ CommandRunner: &relyableRecordingCommandRunner{ - stdout: relyableJSON, - stderr: "", - err: errors.New("exit status 2"), + stdout: relyableJSON, + stderr: "", + err: errors.New("exit status 2"), + exitCode: &exitCode, }, } result, err := runner.runRelyable("/tmp/missing", "2026-01-01T00:00:00Z") @@ -97,6 +100,9 @@ func TestRelyableScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q", result.Status) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if result.Error == "" { t.Fatal("expected the command error to be recorded") } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f1b181a..a0185ff 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "math/big" "net/url" "os" "os/exec" @@ -66,6 +67,17 @@ func (rule ExitCodeRule) Matches(exitCode int) bool { type ScannerGatePolicy struct { BlockOnExitCode *ExitCodeRule WarnOnExitCode *ExitCodeRule + JSONRules []JSONGateRule +} + +type JSONGateRule struct { + ID string + Paths []string + Equals json.RawMessage + Exists bool + Normalize string + Fallback string + Action string } type BenchmarkOptions struct { @@ -134,10 +146,12 @@ type Artifact struct { } type FiredGateRule struct { - Scanner string `json:"scanner"` - Rule string `json:"rule"` - ExitCode int `json:"exitCode"` - Action string `json:"action"` + Scanner string `json:"scanner"` + Rule string `json:"rule"` + ExitCode *int `json:"exitCode,omitempty"` + Path string `json:"path,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + Action string `json:"action"` } type RunTargetsResult struct { @@ -1984,17 +1998,19 @@ func (runner ExternalScannerRunner) runAgentVerus(target string, startedAt strin timeout = 20 * time.Minute } output, runErr := runner.CommandRunner.Run(command, args, "", timeout) + exitCode := gateEligibleExitCode(output.ExitCode) completedAt := time.Now().UTC().Format(time.RFC3339Nano) raw := strings.TrimSpace(output.Stdout) if runErr != nil { message := commandError(runErr, output.Stderr, runner.Env) if json.Valid([]byte(raw)) { return ScannerResult{ - Status: "completed", + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -2033,6 +2049,7 @@ func (runner ExternalScannerRunner) runAgentVerus(target string, startedAt strin CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -2078,6 +2095,7 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str timeout = 20 * time.Minute } output, runErr := runner.CommandRunner.Run(command, args, cwd, timeout) + exitCode := gateEligibleExitCode(output.ExitCode) raw, readErr := os.ReadFile(resultPath) completedAt := time.Now().UTC().Format(time.RFC3339Nano) if runErr != nil { @@ -2090,15 +2108,17 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str CompletedAt: completedAt, Command: fullCommand, Error: message + ": SkillSpector scanner returned invalid JSON", + ExitCode: exitCode, Raw: nil, }, nil } return ScannerResult{ - Status: "completed", + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -2108,6 +2128,7 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: nil, }, nil } @@ -2118,6 +2139,7 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str CompletedAt: completedAt, Command: fullCommand, Error: "SkillSpector scanner did not write JSON output.", + ExitCode: exitCode, Raw: nil, }, nil } @@ -2128,6 +2150,7 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str CompletedAt: completedAt, Command: fullCommand, Error: "SkillSpector scanner returned invalid JSON", + ExitCode: exitCode, Raw: nil, }, nil } @@ -2137,6 +2160,7 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -2261,24 +2285,290 @@ func evaluateGate(artifact *Artifact, opts Options) { } evaluated[scanner] = true result := artifact.Scanners[scanner] - if result.Status != "completed" || result.ExitCode == nil { + if result.Status != "completed" { continue } policy := opts.GateRules[scanner] + if len(policy.JSONRules) > 0 { + evaluateJSONGateRules(artifact, scanner, result.Raw, policy.JSONRules) + } + if result.ExitCode == nil { + continue + } if policy.BlockOnExitCode != nil && policy.BlockOnExitCode.Matches(*result.ExitCode) { artifact.GateRules = append(artifact.GateRules, FiredGateRule{ - Scanner: scanner, Rule: "blockOnExitCode", ExitCode: *result.ExitCode, Action: "block", + Scanner: scanner, Rule: "blockOnExitCode", ExitCode: result.ExitCode, Action: "block", }) - artifact.Gate = "block" + setGateAction(artifact, "block") } if policy.WarnOnExitCode != nil && policy.WarnOnExitCode.Matches(*result.ExitCode) { artifact.GateRules = append(artifact.GateRules, FiredGateRule{ - Scanner: scanner, Rule: "warnOnExitCode", ExitCode: *result.ExitCode, Action: "warn", + Scanner: scanner, Rule: "warnOnExitCode", ExitCode: result.ExitCode, Action: "warn", }) - if artifact.Gate == "pass" { - artifact.Gate = "warn" + setGateAction(artifact, "warn") + } + } +} + +func evaluateJSONGateRules(artifact *Artifact, scanner string, raw json.RawMessage, rules []JSONGateRule) { + if len(raw) == 0 { + return + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var document any + if err := decoder.Decode(&document); err != nil { + return + } + + for _, rule := range rules { + matched := false + matchedPath := "" + var matchedValue json.RawMessage + expected, equalsOK := decodeJSONGateScalar(rule.Equals) + for _, path := range rule.Paths { + values, present, emptyArray, rootPresent := jsonGatePathValues(document, path) + if !present { + if rule.Fallback == "root" && rootPresent { + break + } + continue + } + if rule.Exists { + for _, value := range values { + if jsonGateValueIsNonEmpty(value) { + matched = true + matchedPath = path + break + } + } + if matched || emptyArray || rule.Fallback == "root" && rootPresent { + break + } + continue + } + authoritative := emptyArray || rule.Fallback == "root" && rootPresent + if equalsOK { + for _, value := range values { + if !jsonGateValueIsNonEmpty(value) { + continue + } + authoritative = true + if jsonGateValuesEqual(value, expected, rule.Normalize) { + matched = true + matchedPath = path + matchedValue, _ = json.Marshal(value) + break + } + } } + if matched || authoritative { + break + } + } + if !matched { + continue } + artifact.GateRules = append(artifact.GateRules, FiredGateRule{ + Scanner: scanner, + Rule: rule.ID, + Path: matchedPath, + Value: matchedValue, + Action: rule.Action, + }) + setGateAction(artifact, rule.Action) + } +} + +func ValidateJSONGatePath(path string) error { + if path == "" { + return errors.New("path must not be empty") + } + for _, segment := range strings.Split(path, ".") { + if _, _, ok := parseJSONGatePathSegment(segment); !ok { + return errors.New("path must use dotted object fields with optional [] array traversal") + } + } + return nil +} + +func jsonGatePathValues(document any, path string) ([]any, bool, bool, bool) { + values := []any{document} + rootPresent := false + for index, segment := range strings.Split(path, ".") { + if len(values) == 0 { + return nil, true, true, rootPresent + } + keys, array, _ := parseJSONGatePathSegment(segment) + next := make([]any, 0) + present := false + rootKeyPresent := false + for _, value := range values { + object, ok := value.(map[string]any) + if !ok { + continue + } + if array { + child, ok := object[keys[0]] + if !ok || child == nil { + continue + } + rootKeyPresent = true + items, ok := child.([]any) + if ok { + present = true + next = append(next, items...) + } + continue + } + + for _, key := range keys { + child, ok := object[key] + if !ok || child == nil { + continue + } + rootKeyPresent = true + present = true + next = append(next, child) + break + } + } + if index == 0 { + rootPresent = rootKeyPresent + } + if !present { + return nil, false, false, rootPresent + } + values = next + } + return values, true, len(values) == 0, rootPresent +} + +func parseJSONGatePathSegment(segment string) ([]string, bool, bool) { + array := strings.HasSuffix(segment, "[]") + base := strings.TrimSuffix(segment, "[]") + if base == "" || strings.ContainsAny(base, "[]") { + return nil, false, false + } + keys := strings.Split(base, "|") + if array && len(keys) > 1 { + return nil, false, false + } + for _, key := range keys { + if key == "" { + return nil, false, false + } + } + return keys, array, true +} + +func jsonGateValueIsNonEmpty(value any) bool { + if value == nil { + return false + } + switch value := value.(type) { + case string: + return strings.TrimSpace(value) != "" + case []any: + return len(value) > 0 + case map[string]any: + return len(value) > 0 + default: + return true + } +} + +func decodeJSONGateScalar(raw json.RawMessage) (any, bool) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false + } + switch value.(type) { + case string, bool, json.Number: + return value, true + default: + return nil, false + } +} + +func jsonGateValuesEqual(actual any, expected any, normalize string) bool { + switch expected := expected.(type) { + case string: + actual, ok := actual.(string) + if ok && normalize == "identifier" { + actual = normalizeJSONGateIdentifier(actual) + expected = normalizeJSONGateIdentifier(expected) + } + return ok && actual == expected + case bool: + actual, ok := actual.(bool) + return ok && actual == expected + case json.Number: + actual, ok := actual.(json.Number) + if !ok { + return false + } + actualNegative, actualDigits, actualExponent, actualOK := canonicalJSONGateNumber(actual) + expectedNegative, expectedDigits, expectedExponent, expectedOK := canonicalJSONGateNumber(expected) + return actualOK && expectedOK && + actualNegative == expectedNegative && + actualDigits == expectedDigits && + actualExponent.Cmp(expectedExponent) == 0 + default: + return false + } +} + +func canonicalJSONGateNumber(value json.Number) (bool, string, *big.Int, bool) { + text := value.String() + negative := strings.HasPrefix(text, "-") + if negative { + text = strings.TrimPrefix(text, "-") + } + mantissa := text + exponentText := "" + if index := strings.IndexAny(text, "eE"); index >= 0 { + mantissa = text[:index] + exponentText = text[index+1:] + } + exponent := new(big.Int) + if exponentText != "" { + if _, ok := exponent.SetString(exponentText, 10); !ok { + return false, "", nil, false + } + } + integer, fraction, hasFraction := strings.Cut(mantissa, ".") + digits := integer + if hasFraction { + digits += fraction + exponent.Sub(exponent, big.NewInt(int64(len(fraction)))) + } + digits = strings.TrimLeft(digits, "0") + if digits == "" { + return false, "0", new(big.Int), true + } + trimmed := strings.TrimRight(digits, "0") + exponent.Add(exponent, big.NewInt(int64(len(digits)-len(trimmed)))) + return negative, trimmed, exponent, true +} + +func normalizeJSONGateIdentifier(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + return strings.NewReplacer(" ", "_", "-", "_").Replace(value) +} + +func commandScannerResultStatus(output CommandOutput, runErr error) string { + if runErr != nil && gateEligibleExitCode(output.ExitCode) == nil { + return "failed" + } + return "completed" +} + +func setGateAction(artifact *Artifact, action string) { + if action == "block" || action == "warn" && artifact.Gate == "pass" { + artifact.Gate = action } } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index e9a7c23..abd6974 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1557,7 +1557,7 @@ func TestRunBlocksWhenNonzeroExitCodeRuleFires(t *testing.T) { if artifact.Gate != "block" { t.Fatalf("gate = %q", artifact.Gate) } - want := []FiredGateRule{{Scanner: "clawscan-static", Rule: "blockOnExitCode", ExitCode: 2, Action: "block"}} + want := []FiredGateRule{{Scanner: "clawscan-static", Rule: "blockOnExitCode", ExitCode: intPointer(2), Action: "block"}} if !reflect.DeepEqual(artifact.GateRules, want) { t.Fatalf("gate rules = %#v", artifact.GateRules) } @@ -1618,6 +1618,486 @@ func TestRunExitCodeGateActionsAndPrecedence(t *testing.T) { } } +func TestRunAppliesDeclarativeSkillSpectorGatePolicy(t *testing.T) { + manyFindings := make([]map[string]string, maxClawHubSkillSpectorIssues+1) + for index := range manyFindings { + manyFindings[index] = map[string]string{"rule_id": fmt.Sprintf("LOW-%d", index+1), "severity": "LOW"} + } + manyFindings[len(manyFindings)-1] = map[string]string{"rule_id": "LATE-CRITICAL", "severity": "CRITICAL"} + lateCriticalRaw, err := json.Marshal(map[string]any{"filtered_findings": manyFindings}) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + raw json.RawMessage + want string + rule string + path string + value json.RawMessage + }{ + { + name: "safe passes", + raw: json.RawMessage(`{"risk_assessment":{"recommendation":"SAFE"},"filtered_findings":[]}`), + want: "pass", + }, + { + name: "caution passes without high findings", + raw: json.RawMessage(`{"risk_assessment":{"recommendation":"CAUTION"},"filtered_findings":[{"rule_id":"MED-1","severity":"MEDIUM"}]}`), + want: "pass", + }, + { + name: "do not install blocks", + raw: json.RawMessage(`{"risk_assessment":{"recommendation":"DO_NOT_INSTALL"},"filtered_findings":[]}`), + want: "block", + rule: "do-not-install", + path: "risk_assessment.recommendation", + value: json.RawMessage(`"DO_NOT_INSTALL"`), + }, + { + name: "critical finding blocks", + raw: json.RawMessage(`{"risk_assessment":{"recommendation":"CAUTION"},"filtered_findings":[{"rule_id":"CRIT-1","severity":"CRITICAL"}]}`), + want: "block", + rule: "critical-finding", + path: "filtered_findings[].severity", + value: json.RawMessage(`"CRITICAL"`), + }, + { + name: "high finding warns", + raw: json.RawMessage(`{"risk_assessment":{"recommendation":"CAUTION"},"filtered_findings":[{"rule_id":"HIGH-1","severity":"HIGH"},{"rule_id":"HIGH-2","severity":"HIGH"}]}`), + want: "warn", + rule: "high-finding", + path: "filtered_findings[].severity", + value: json.RawMessage(`"HIGH"`), + }, + { + name: "critical finding after prompt display cap blocks", + raw: lateCriticalRaw, + want: "block", + rule: "critical-finding", + path: "filtered_findings[].severity", + value: json.RawMessage(`"CRITICAL"`), + }, + } + rules := []JSONGateRule{ + {ID: "do-not-install", Paths: []string{"risk_assessment.recommendation"}, Equals: json.RawMessage(`"DO_NOT_INSTALL"`), Action: "block"}, + {ID: "critical-finding", Paths: []string{"filtered_findings[].severity"}, Equals: json.RawMessage(`"CRITICAL"`), Action: "block"}, + {ID: "high-finding", Paths: []string{"filtered_findings[].severity"}, Equals: json.RawMessage(`"HIGH"`), Action: "warn"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original := append(json.RawMessage(nil), test.raw...) + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: rules}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: test.raw}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != test.want { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + if artifact.Judge != nil { + t.Fatalf("declarative gate unexpectedly invoked judge: %#v", artifact.Judge) + } + if test.rule == "" { + if len(artifact.GateRules) != 0 { + t.Fatalf("gate rules = %#v", artifact.GateRules) + } + } else { + if len(artifact.GateRules) != 1 { + t.Fatalf("gate rules = %#v", artifact.GateRules) + } + rule := artifact.GateRules[0] + if rule.Rule != test.rule || rule.Path != test.path || !bytes.Equal(rule.Value, test.value) { + t.Fatalf("gate rule = %#v", rule) + } + } + if !bytes.Equal(artifact.Scanners["skillspector"].Raw, original) { + t.Fatalf("raw evidence changed\nwant %s\ngot %s", original, artifact.Scanners["skillspector"].Raw) + } + }) + } +} + +func TestRunAppliesExistsRuleToStaticFindings(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + fired int + }{ + { + name: "clean report passes", + raw: json.RawMessage(`{"schemaVersion":"clawscan-static-v1","findings":[]}`), + want: "pass", + }, + { + name: "medium finding warns", + raw: json.RawMessage(`{"schemaVersion":"clawscan-static-v1","findings":[{"id":"static.prompt_injection","title":"Prompt injection","severity":"medium"}]}`), + want: "warn", + fired: 1, + }, + { + name: "high finding still only warns", + raw: json.RawMessage(`{"schemaVersion":"clawscan-static-v1","findings":[{"id":"static.destructive_shell","title":"Destructive shell","severity":"high"}]}`), + want: "warn", + fired: 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"clawscan-static"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"clawscan-static": {JSONRules: []JSONGateRule{ + {ID: "any-finding", Paths: []string{"findings[]"}, Exists: true, Action: "warn"}, + }}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "clawscan-static": {Status: "completed", Raw: test.raw}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != test.want || len(artifact.GateRules) != test.fired { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + for _, rule := range artifact.GateRules { + if rule.Rule != "any-finding" || rule.Path != "findings[]" || rule.Value != nil || rule.Action != "warn" { + t.Fatalf("static gate rule = %#v", rule) + } + } + }) + } +} + +func TestRunDeclarativeJSONRulesMatchNumberAndBooleanValues(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{ + {ID: "score-threshold", Paths: []string{"result.score"}, Equals: json.RawMessage(`7`), Action: "warn"}, + {ID: "not-approved", Paths: []string{"result.approved"}, Equals: json.RawMessage(`false`), Action: "block"}, + }}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: json.RawMessage(`{"result":{"score":7.0,"approved":false}}`)}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 2 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + if !bytes.Equal(artifact.GateRules[0].Value, []byte(`7.0`)) || !bytes.Equal(artifact.GateRules[1].Value, []byte(`false`)) { + t.Fatalf("matched values = %#v", artifact.GateRules) + } +} + +func TestRunDeclarativeJSONRuleUsesFirstPresentPath(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + want string + }{ + { + name: "preferred value does not match", + raw: json.RawMessage(`{"preferred":[{"severity":"low"}],"legacy":[{"severity":"critical"}]}`), + want: "pass", + }, + { + name: "preferred array is empty", + raw: json.RawMessage(`{"preferred":[],"legacy":[{"severity":"critical"}]}`), + want: "pass", + }, + { + name: "preferred path is absent", + raw: json.RawMessage(`{"legacy":[{"severity":"critical"}]}`), + want: "block", + }, + { + name: "preferred nested field is absent", + raw: json.RawMessage(`{"preferred":[{}],"legacy":[{"severity":"critical"}]}`), + want: "block", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "critical-finding", Paths: []string{"preferred[].severity", "legacy[].severity"}, + Equals: json.RawMessage(`"critical"`), Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: test.raw}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != test.want { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + }) + } +} + +func TestRunDeclarativeJSONRuleCanPreferAnExistingRoot(t *testing.T) { + for _, raw := range []json.RawMessage{ + json.RawMessage(`{"preferred":[{}],"legacy":[{"severity":"critical"}]}`), + json.RawMessage(`{"preferred":{},"legacy":[{"severity":"critical"}]}`), + } { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "critical-finding", Paths: []string{"preferred[].severity", "legacy[].severity"}, + Equals: json.RawMessage(`"critical"`), Fallback: "root", Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: raw}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "pass" || len(artifact.GateRules) != 0 { + t.Fatalf("raw = %s, gate = %q, rules = %#v", raw, artifact.Gate, artifact.GateRules) + } + } +} + +func TestRunDeclarativeJSONRuleFallsBackFromEmptyScalarValues(t *testing.T) { + for _, raw := range []json.RawMessage{ + json.RawMessage(`{"preferred":"","legacy":"critical"}`), + json.RawMessage(`{"preferred":" ","legacy":"critical"}`), + json.RawMessage(`{"preferred":null,"legacy":"critical"}`), + } { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "critical-risk", Paths: []string{"preferred", "legacy"}, + Equals: json.RawMessage(`"critical"`), Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: raw}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 || artifact.GateRules[0].Path != "legacy" { + t.Fatalf("raw = %s, gate = %q, rules = %#v", raw, artifact.Gate, artifact.GateRules) + } + } +} + +func TestRunDeclarativeExistsRuleFallsBackFromEmptyValues(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "evidence", Paths: []string{"preferred", "legacy"}, Exists: true, Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: json.RawMessage(`{"preferred":null,"legacy":"evidence"}`)}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 || artifact.GateRules[0].Path != "legacy" { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + +func TestRunDeclarativeJSONRuleResolvesFieldAliasesPerArrayItem(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "critical-finding", Paths: []string{"findings[].severity|risk_severity|level"}, + Equals: json.RawMessage(`"critical"`), Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": { + Status: "completed", + Raw: json.RawMessage(`{"findings":[{"severity":"low"},{"risk_severity":"critical"}]}`), + }, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + +func TestRunDeclarativeJSONRulesDoNotMatchEmptyValues(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{ + {ID: "blank-exists", Paths: []string{"blank"}, Exists: true, Action: "block"}, + {ID: "null-exists", Paths: []string{"nothing"}, Exists: true, Action: "block"}, + {ID: "empty-array-exists", Paths: []string{"items[]"}, Exists: true, Action: "block"}, + {ID: "empty-array-value-exists", Paths: []string{"items"}, Exists: true, Action: "block"}, + {ID: "empty-object-exists", Paths: []string{"metadata"}, Exists: true, Action: "block"}, + {ID: "blank-equals", Paths: []string{"blank"}, Equals: json.RawMessage(`""`), Action: "block"}, + }}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: json.RawMessage(`{"blank":" ","nothing":null,"items":[],"metadata":{}}`)}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "pass" || len(artifact.GateRules) != 0 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + +func TestRunDeclarativeJSONRuleComparesLargeNumbersExactly(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{ + {ID: "different-large-number", Paths: []string{"result.sequence"}, Equals: json.RawMessage(`9007199254740992`), Action: "block"}, + }}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: json.RawMessage(`{"result":{"sequence":9007199254740993}}`)}, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "pass" || len(artifact.GateRules) != 0 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + +func TestRunDeclarativeAndExitCodeGateRulesComposeOnOneScanner(t *testing.T) { + exitCode := 1 + commandRunner := &recordingCommandRunner{ + writeOutput: `{"filtered_findings":[{"rule_id":"HIGH-1","severity":"HIGH"}]}`, + err: errCommandFailed, + exitCode: &exitCode, + } + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": { + JSONRules: []JSONGateRule{{ID: "high-finding", Paths: []string{"filtered_findings[].severity"}, Equals: json.RawMessage(`"HIGH"`), Action: "warn"}}, + BlockOnExitCode: &ExitCodeRule{Codes: []int{1}}, + }}, + }, RunContext{ + Env: map[string]string{}, + CommandRunner: commandRunner, + SkillSpectorCommand: []string{"skillspector"}, + }) + if err != nil { + t.Fatal(err) + } + result := artifact.Scanners["skillspector"] + if result.Status != "completed" || result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("scanner result = %#v", result) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 2 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + if artifact.GateRules[0].Action != "warn" || artifact.GateRules[1].Action != "block" { + t.Fatalf("gate rule order/actions = %#v", artifact.GateRules) + } +} + +func TestRunDeclarativeGatePolicyPreservesRawEvidenceIdentity(t *testing.T) { + raw := json.RawMessage("{\n \"risk_assessment\": {\"recommendation\": \"DO_NOT_INSTALL\"},\n \"filtered_findings\": []\n}\n") + run := func(enabled bool) Artifact { + t.Helper() + var rules []JSONGateRule + if enabled { + rules = []JSONGateRule{{ID: "do-not-install", Paths: []string{"risk_assessment.recommendation"}, Equals: json.RawMessage(`"DO_NOT_INSTALL"`), Action: "block"}} + } + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: rules}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": {Status: "completed", Raw: append(json.RawMessage(nil), raw...)}, + }}}) + if err != nil { + t.Fatal(err) + } + return artifact + } + + withoutPolicy := run(false) + withPolicy := run(true) + if !bytes.Equal(withoutPolicy.Scanners["skillspector"].Raw, raw) { + t.Fatalf("raw evidence without policy changed: %q", withoutPolicy.Scanners["skillspector"].Raw) + } + if !bytes.Equal(withPolicy.Scanners["skillspector"].Raw, raw) { + t.Fatalf("raw evidence with policy changed: %q", withPolicy.Scanners["skillspector"].Raw) + } + if !bytes.Equal(withPolicy.Scanners["skillspector"].Raw, withoutPolicy.Scanners["skillspector"].Raw) { + t.Fatal("raw evidence differs with declarative policy enabled") + } +} + +func TestRunDeclarativeGatePolicySkipsInfrastructureFailureEvidence(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"demo"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"demo": { + JSONRules: []JSONGateRule{{ + ID: "critical-risk", Paths: []string{"result.risk"}, Equals: json.RawMessage(`"critical"`), Action: "block", + }}, + }}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "demo": { + Status: "failed", + Error: "command timed out after 20m", + Raw: json.RawMessage(`{"result":{"risk":"critical"}}`), + }, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "pass" || len(artifact.GateRules) != 0 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } + if string(artifact.Scanners["demo"].Raw) != `{"result":{"risk":"critical"}}` { + t.Fatalf("raw evidence changed: %s", artifact.Scanners["demo"].Raw) + } +} + +func TestRunDeclarativeGatePolicyEvaluatesCompletedNonzeroEvidenceWithoutExitCode(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"snyk"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"snyk": { + JSONRules: []JSONGateRule{{ + ID: "policy-violation", Paths: []string{"ok"}, Equals: json.RawMessage(`false`), Action: "block", + }}, + }}, + }, RunContext{Env: map[string]string{"SNYK_TOKEN": "present"}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "snyk": { + Status: "completed", + Error: "exit status 1: policy violation", + Raw: json.RawMessage(`{"ok":false}`), + }, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + +func TestJSONGateNumericComparisonDoesNotExpandExponents(t *testing.T) { + if jsonGateValuesEqual(json.Number("1e1000000000"), json.Number("1"), "") { + t.Fatal("different numeric values compared equal") + } + if !jsonGateValuesEqual(json.Number("10e999999999"), json.Number("1e1000000000"), "") { + t.Fatal("equivalent numeric values compared unequal") + } +} + +func TestJSONGateNumericComparisonTreatsNegativeZeroAsZero(t *testing.T) { + if !jsonGateValuesEqual(json.Number("-0"), json.Number("0"), "") { + t.Fatal("negative zero compared unequal to zero") + } + if !jsonGateValuesEqual(json.Number("0.0"), json.Number("-0e1000000000"), "") { + t.Fatal("equivalent zero spellings compared unequal") + } +} + func TestRunBlockGateBeatsWarnAcrossScanners(t *testing.T) { target := t.TempDir() artifact, err := Run(Options{ @@ -2755,9 +3235,10 @@ func TestAgentVerusReportWithNonZeroExitIsCompletedEvidence(t *testing.T) { if err := os.Mkdir(target, 0o755); err != nil { t.Fatal(err) } + exitCode := 1 runner := &recordingCommandRunner{ stdout: `{"overall":42,"badge":"warning","findings":[{"id":"ASST-09"}]}`, - err: errCommandFailed, + err: errCommandFailed, exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "agentverus"}) if err != nil { @@ -2774,6 +3255,9 @@ func TestAgentVerusReportWithNonZeroExitIsCompletedEvidence(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q error = %q", result.Status, result.Error) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if !bytes.Contains(result.Raw, []byte(`"ASST-09"`)) { t.Fatalf("raw = %s", result.Raw) } @@ -2840,9 +3324,10 @@ func TestSkillSpectorReportWithNonZeroExitIsCompletedEvidence(t *testing.T) { if err := os.Mkdir(target, 0o755); err != nil { t.Fatal(err) } + exitCode := 1 runner := &recordingCommandRunner{ writeOutput: `{"risk_assessment":{"severity":"HIGH"},"issues":[{"id":"x"}]}`, - err: errCommandFailed, + err: errCommandFailed, exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "skillspector"}) if err != nil { diff --git a/internal/runner/scanner_registry_test.go b/internal/runner/scanner_registry_test.go index 1d5b23c..96bc35c 100644 --- a/internal/runner/scanner_registry_test.go +++ b/internal/runner/scanner_registry_test.go @@ -243,7 +243,7 @@ func TestUserDefinedScannerPreservesEvidenceWithoutGatingInfrastructureExitCodes if err != nil { t.Fatal(err) } - if result.Status != "completed" || result.ExitCode != nil || string(result.Raw) != `{}` { + if result.Status != "failed" || result.ExitCode != nil || string(result.Raw) != `{}` { t.Fatalf("result = %#v", result) } }) diff --git a/internal/runner/snyk_scanner.go b/internal/runner/snyk_scanner.go index 9ee966a..46eeaaa 100644 --- a/internal/runner/snyk_scanner.go +++ b/internal/runner/snyk_scanner.go @@ -22,17 +22,19 @@ func (runner ExternalScannerRunner) runSnyk(target string, startedAt string) (Sc timeout = 20 * time.Minute } output, runErr := runner.CommandRunner.Run(command, args, "", timeout) + exitCode := gateEligibleExitCode(output.ExitCode) completedAt := time.Now().UTC().Format(time.RFC3339Nano) raw := strings.TrimSpace(output.Stdout) if runErr != nil { message := scannerCommandError(runErr, output.Stderr, runner.Env) if json.Valid([]byte(raw)) { return ScannerResult{ - Status: "completed", + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -71,6 +73,7 @@ func (runner ExternalScannerRunner) runSnyk(target string, startedAt string) (Sc CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } diff --git a/internal/runner/snyk_scanner_test.go b/internal/runner/snyk_scanner_test.go index 5ef8d98..43387dd 100644 --- a/internal/runner/snyk_scanner_test.go +++ b/internal/runner/snyk_scanner_test.go @@ -67,10 +67,12 @@ func TestSnykScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { t.Fatal(err) } const snykJSON = `{"ok":false,"issues":[{"id":"prompt-injection"}]}` + exitCode := 1 runner := &snykRecordingCommandRunner{ - stdout: snykJSON, - stderr: "policy violation", - err: errors.New("exit status 1"), + stdout: snykJSON, + stderr: "policy violation", + err: errors.New("exit status 1"), + exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "snyk"}) if err != nil { @@ -87,12 +89,18 @@ func TestSnykScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q error = %q", result.Status, result.Error) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if !strings.Contains(result.Error, "exit status 1") || !strings.Contains(result.Error, "policy violation") { t.Fatalf("error = %q", result.Error) } if !bytes.Equal(result.Raw, []byte(snykJSON)) { t.Fatalf("raw = %s", result.Raw) } + if result.Status != "completed" { + t.Fatal("policy-violation exit was classified as an infrastructure failure") + } } func TestSnykScannerFailsNonZeroExitWithoutJSONStdout(t *testing.T) { @@ -226,13 +234,14 @@ func TestSnykScannerResultFixtureSkipsTokenRequirement(t *testing.T) { } type snykRecordingCommandRunner struct { - calls []commandCall - stdout string - stderr string - err error + calls []commandCall + stdout string + stderr string + err error + exitCode *int } func (r *snykRecordingCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) { r.calls = append(r.calls, commandCall{command: command, args: append([]string(nil), args...), cwd: cwd}) - return CommandOutput{Stdout: r.stdout, Stderr: r.stderr}, r.err + return CommandOutput{Stdout: r.stdout, Stderr: r.stderr, ExitCode: r.exitCode}, r.err } diff --git a/internal/runner/socket_scanner.go b/internal/runner/socket_scanner.go index 3f37e8e..c116535 100644 --- a/internal/runner/socket_scanner.go +++ b/internal/runner/socket_scanner.go @@ -22,17 +22,19 @@ func (runner ExternalScannerRunner) runSocket(target string, startedAt string) ( timeout = 20 * time.Minute } output, runErr := runner.CommandRunner.Run(command, args, "", timeout) + exitCode := gateEligibleExitCode(output.ExitCode) completedAt := time.Now().UTC().Format(time.RFC3339Nano) raw := strings.TrimSpace(output.Stdout) if runErr != nil { message := scannerCommandError(runErr, output.Stderr, runner.Env) if json.Valid([]byte(raw)) { return ScannerResult{ - Status: "completed", + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } @@ -71,6 +73,7 @@ func (runner ExternalScannerRunner) runSocket(target string, startedAt string) ( CompletedAt: completedAt, Command: fullCommand, Error: "", + ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } diff --git a/internal/runner/socket_scanner_test.go b/internal/runner/socket_scanner_test.go index 085714a..b636163 100644 --- a/internal/runner/socket_scanner_test.go +++ b/internal/runner/socket_scanner_test.go @@ -92,10 +92,12 @@ func TestSocketScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { t.Fatal(err) } const socketJSON = `{"id":"scan_123","status":"failed","alerts":[]}` + exitCode := 1 runner := &socketRecordingCommandRunner{ - stdout: socketJSON, - stderr: "policy violation", - err: errors.New("exit status 1"), + stdout: socketJSON, + stderr: "policy violation", + err: errors.New("exit status 1"), + exitCode: &exitCode, } opts, err := ParseArgs([]string{target, "--scanner", "socket"}) if err != nil { @@ -112,6 +114,9 @@ func TestSocketScannerCompletesNonZeroExitWithJSONStdout(t *testing.T) { if result.Status != "completed" { t.Fatalf("status = %q error = %q", result.Status, result.Error) } + if result.ExitCode == nil || *result.ExitCode != exitCode { + t.Fatalf("exit code = %#v", result.ExitCode) + } if !strings.Contains(result.Error, "exit status 1") || !strings.Contains(result.Error, "policy violation") { t.Fatalf("error = %q", result.Error) } @@ -251,13 +256,14 @@ func TestSocketScannerResultFixtureSkipsTokenRequirement(t *testing.T) { } type socketRecordingCommandRunner struct { - calls []commandCall - stdout string - stderr string - err error + calls []commandCall + stdout string + stderr string + err error + exitCode *int } func (r *socketRecordingCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) { r.calls = append(r.calls, commandCall{command: command, args: append([]string(nil), args...), cwd: cwd}) - return CommandOutput{Stdout: r.stdout, Stderr: r.stderr}, r.err + return CommandOutput{Stdout: r.stdout, Stderr: r.stderr, ExitCode: r.exitCode}, r.err } diff --git a/internal/runner/user_defined_scanner.go b/internal/runner/user_defined_scanner.go index c0a8b6f..ed7284c 100644 --- a/internal/runner/user_defined_scanner.go +++ b/internal/runner/user_defined_scanner.go @@ -116,7 +116,7 @@ func (adapter userDefinedScannerAdapter) Run(runner ExternalScannerRunner, targe message := commandErrorForEnvNames(runErr, output.Stderr, runner.Env, adapter.config.SecretEnv) if json.Valid([]byte(raw)) { return ScannerResult{ - Status: "completed", StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, + Status: commandScannerResultStatus(output, runErr), StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand, Error: message, ExitCode: exitCode, Raw: json.RawMessage(raw), }, nil } From 34cc64d6edff7565171434024508bbc2670c0626 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:54:28 +1000 Subject: [PATCH 02/28] feat(plugin): add OpenClaw install gate --- .../workflows/clawscan-plugin-self-scan.yml | 61 ++++ .github/workflows/npm-release.yml | 62 ++-- npm/clawscan-plugin/README.md | 33 +++ npm/clawscan-plugin/index.ts | 10 + npm/clawscan-plugin/openclaw.plugin.json | 25 ++ npm/clawscan-plugin/package.json | 59 ++++ npm/clawscan-plugin/profiles/clawhub.yml | 16 ++ npm/clawscan-plugin/src/artifact.ts | 200 +++++++++++++ npm/clawscan-plugin/src/gate-handler.ts | 178 ++++++++++++ npm/clawscan-plugin/src/register.ts | 57 ++++ npm/clawscan-plugin/test/artifact.test.ts | 265 +++++++++++++++++ npm/clawscan-plugin/test/gate-handler.test.ts | 271 ++++++++++++++++++ npm/clawscan-plugin/test/package.test.mjs | 72 +++++ npm/clawscan-plugin/test/registration.test.ts | 80 ++++++ npm/clawscan/lib/resolve-binary.d.mts | 17 ++ npm/clawscan/lib/resolve-binary.mjs | 12 +- npm/clawscan/package.json | 20 +- npm/clawscan/test/resolve-binary.test.mjs | 16 +- scripts/build-npm-package.mjs | 156 +++++++--- scripts/build-npm-package.test.mjs | 21 ++ 20 files changed, 1556 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/clawscan-plugin-self-scan.yml create mode 100644 npm/clawscan-plugin/README.md create mode 100644 npm/clawscan-plugin/index.ts create mode 100644 npm/clawscan-plugin/openclaw.plugin.json create mode 100644 npm/clawscan-plugin/package.json create mode 100644 npm/clawscan-plugin/profiles/clawhub.yml create mode 100644 npm/clawscan-plugin/src/artifact.ts create mode 100644 npm/clawscan-plugin/src/gate-handler.ts create mode 100644 npm/clawscan-plugin/src/register.ts create mode 100644 npm/clawscan-plugin/test/artifact.test.ts create mode 100644 npm/clawscan-plugin/test/gate-handler.test.ts create mode 100644 npm/clawscan-plugin/test/package.test.mjs create mode 100644 npm/clawscan-plugin/test/registration.test.ts create mode 100644 npm/clawscan/lib/resolve-binary.d.mts diff --git a/.github/workflows/clawscan-plugin-self-scan.yml b/.github/workflows/clawscan-plugin-self-scan.yml new file mode 100644 index 0000000..0dd7aeb --- /dev/null +++ b/.github/workflows/clawscan-plugin-self-scan.yml @@ -0,0 +1,61 @@ +name: ClawScan Plugin Self-Scan + +on: + pull_request: + paths: + - ".github/workflows/clawscan-plugin-self-scan.yml" + - "cmd/clawscan/**" + - "internal/**" + - "npm/clawscan-plugin/**" + push: + branches: + - main + paths: + - ".github/workflows/clawscan-plugin-self-scan.yml" + - "cmd/clawscan/**" + - "internal/**" + - "npm/clawscan-plugin/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Scan the OpenClaw plugin + env: + CLAWSCAN_SKILLSPECTOR_LLM: "0" + run: | + set -euo pipefail + artifact="${RUNNER_TEMP}/clawscan-plugin-artifact.json" + go run ./cmd/clawscan ./npm/clawscan-plugin \ + --config ./npm/clawscan-plugin/profiles/clawhub.yml \ + --profile clawhub \ + --sandbox docker \ + --json \ + --output "$artifact" + # shellcheck disable=SC2016 + node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const artifact = JSON.parse(readFileSync(process.argv[1], "utf8")); + const required = ["skillspector", "clawscan-static"]; + if (artifact.schemaVersion !== "clawscan-run-v1" || artifact.gate !== "pass") { + throw new Error(`ClawScan plugin self-scan gate was ${artifact.gate ?? "invalid"}`); + } + for (const scanner of required) { + if (artifact.scanners?.[scanner]?.status !== "completed") { + throw new Error(`Required self-scan scanner ${scanner} did not complete`); + } + } + ' "$artifact" diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 09a9894..5b4d133 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -60,9 +60,10 @@ jobs: - name: Test npm packaging helpers run: | node --test npm/clawscan/test/*.test.mjs + node --test npm/clawscan-plugin/test/*.test.mjs npm/clawscan-plugin/test/*.test.ts node --test scripts/build-npm-package.test.mjs - - name: Build and smoke packed npm package + - name: Build and smoke packed npm packages run: node scripts/build-npm-package.mjs --version "${{ inputs.tag }}" --pack --smoke - name: Upload prepared npm publish bundle @@ -185,42 +186,51 @@ jobs: fi echo "PACKAGE_VERSION=$EXPECTED_PACKAGE_VERSION" >> "$GITHUB_ENV" - - name: Resolve publish tarball - id: publish_tarball + - name: Resolve publish tarballs + id: publish_tarballs run: | set -euo pipefail - TARBALL_PATH="$(find dist/npm -type f -name 'openclaw-clawscan-*.tgz' -print | sort | tail -n 1)" - if [[ -z "$TARBALL_PATH" ]]; then - echo "Prepared preflight tarball not found." >&2 + CLAWSCAN_TARBALL="dist/npm/openclaw-clawscan-${PACKAGE_VERSION}.tgz" + PLUGIN_TARBALL="dist/npm/openclaw-clawscan-plugin-${PACKAGE_VERSION}.tgz" + if [[ ! -f "$CLAWSCAN_TARBALL" || ! -f "$PLUGIN_TARBALL" ]]; then + echo "Prepared preflight tarballs were not both present." >&2 ls -la dist/npm >&2 || true exit 1 fi - echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT" + echo "clawscan_path=$CLAWSCAN_TARBALL" >> "$GITHUB_OUTPUT" + echo "plugin_path=$PLUGIN_TARBALL" >> "$GITHUB_OUTPUT" - - name: Ensure version is not already published + - name: Ensure versions are not already published run: | set -euo pipefail - if npm view "@openclaw/clawscan@${PACKAGE_VERSION}" version >/dev/null 2>&1; then - echo "@openclaw/clawscan@${PACKAGE_VERSION} is already published on npm." - exit 1 - fi - echo "Publishing @openclaw/clawscan@${PACKAGE_VERSION}" + for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do + if npm view "${package_name}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "${package_name}@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + echo "Publishing ${package_name}@${PACKAGE_VERSION}" + done - - name: Publish - run: npm publish "${{ steps.publish_tarball.outputs.path }}" --access public --provenance + - name: Publish ClawScan binary package + run: npm publish "${{ steps.publish_tarballs.outputs.clawscan_path }}" --access public --provenance + + - name: Publish ClawScan OpenClaw plugin + run: npm publish "${{ steps.publish_tarballs.outputs.plugin_path }}" --access public --provenance - name: Verify npm release metadata run: | set -euo pipefail - NPM_DIST_JSON="" - for attempt in {1..12}; do - if NPM_DIST_JSON="$(npm view "@openclaw/clawscan@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then - break - fi - if [[ "$attempt" == "12" ]]; then - cat /tmp/npm-view-error >&2 || true - exit 1 - fi - sleep 5 + for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do + NPM_DIST_JSON="" + for attempt in {1..12}; do + if NPM_DIST_JSON="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then + break + fi + if [[ "$attempt" == "12" ]]; then + cat /tmp/npm-view-error >&2 || true + exit 1 + fi + sleep 5 + done + printf '%s\n' "$NPM_DIST_JSON" done - printf '%s\n' "$NPM_DIST_JSON" diff --git a/npm/clawscan-plugin/README.md b/npm/clawscan-plugin/README.md new file mode 100644 index 0000000..8db371f --- /dev/null +++ b/npm/clawscan-plugin/README.md @@ -0,0 +1,33 @@ +# ClawScan Install Gate for OpenClaw + +`@openclaw/clawscan-plugin` registers OpenClaw's `before_install` hook and +fails closed when ClawScan cannot produce a trustworthy gate artifact. + +Install the plugin, then explicitly trust and enable it: + +```sh +openclaw plugins install @openclaw/clawscan-plugin +openclaw plugins enable clawscan +``` + +This writes `plugins.entries.clawscan.enabled=true`. If your OpenClaw +configuration uses `plugins.allow`, add `clawscan` to that list as well. +Installation alone does not activate this install hook. + +By default, every candidate skill or plugin is scanned with SkillSpector +(`CLAWSCAN_SKILLSPECTOR_LLM=0`) and `clawscan-static` inside ClawScan's Docker +sandbox. This no-LLM mode does not send source files to a model provider, but +SkillSpector still sends dependency names to [OSV.dev](https://osv.dev/) for +CVE lookups. + +If Docker is unavailable, the plugin visibly reports that the gate is degraded +and runs only `clawscan-static` with the sandbox disabled. This fallback is a +small static tripwire, not equivalent protection. + +The plugin accepts only an explicit `configPath` and `profile`. Relative config +paths resolve from the plugin directory; the untrusted candidate directory is +never searched for ClawScan configuration. + +The gate cannot scan its own first installation because its hook is not active +yet. Enable it immediately after installation. Once enabled, it scans +subsequent updates, including updates to itself. diff --git a/npm/clawscan-plugin/index.ts b/npm/clawscan-plugin/index.ts new file mode 100644 index 0000000..dd3651c --- /dev/null +++ b/npm/clawscan-plugin/index.ts @@ -0,0 +1,10 @@ +import { resolveBundledBinaryPath } from "@openclaw/clawscan/resolve-binary"; +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { registerInstallGate } from "./src/register.ts"; + +export default definePluginEntry({ + id: "clawscan", + name: "ClawScan Install Gate", + description: "Scans candidate skills and plugins before OpenClaw installs or updates them.", + register: (api: OpenClawPluginApi) => registerInstallGate(api, resolveBundledBinaryPath), +}); diff --git a/npm/clawscan-plugin/openclaw.plugin.json b/npm/clawscan-plugin/openclaw.plugin.json new file mode 100644 index 0000000..7257d21 --- /dev/null +++ b/npm/clawscan-plugin/openclaw.plugin.json @@ -0,0 +1,25 @@ +{ + "id": "clawscan", + "activation": { + "onStartup": false, + "onCapabilities": ["hook"] + }, + "name": "ClawScan Install Gate", + "description": "Scans candidate skills and plugins before OpenClaw installs or updates them.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "configPath": { + "type": "string", + "minLength": 1, + "description": "Explicit ClawScan config path. Relative paths resolve inside this plugin." + }, + "profile": { + "type": "string", + "minLength": 1, + "description": "Profile selected from the explicit config path." + } + } + } +} diff --git a/npm/clawscan-plugin/package.json b/npm/clawscan-plugin/package.json new file mode 100644 index 0000000..5e11e4a --- /dev/null +++ b/npm/clawscan-plugin/package.json @@ -0,0 +1,59 @@ +{ + "name": "@openclaw/clawscan-plugin", + "version": "0.0.0-dev", + "description": "Fail-closed ClawScan install gate for OpenClaw skills and plugins.", + "homepage": "https://github.com/openclaw/clawscan#openclaw-install-gate", + "bugs": { + "url": "https://github.com/openclaw/clawscan/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/openclaw/clawscan.git" + }, + "files": [ + "index.ts", + "src/", + "profiles/", + "openclaw.plugin.json", + "LICENSE", + "README.md" + ], + "type": "module", + "publishConfig": { + "access": "public", + "provenance": true + }, + "scripts": { + "test": "node --test test/*.test.mjs test/*.test.ts" + }, + "dependencies": { + "@openclaw/clawscan": "0.0.0-dev" + }, + "engines": { + "node": ">=22.22.3" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "install": { + "clawhubSpec": "clawhub:@openclaw/clawscan-plugin", + "npmSpec": "@openclaw/clawscan-plugin", + "defaultChoice": "npm", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2", + "bundledDist": false + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true, + "bundleRuntimeDependencies": false + } + } +} diff --git a/npm/clawscan-plugin/profiles/clawhub.yml b/npm/clawscan-plugin/profiles/clawhub.yml new file mode 100644 index 0000000..4aacb1b --- /dev/null +++ b/npm/clawscan-plugin/profiles/clawhub.yml @@ -0,0 +1,16 @@ +version: 1 + +profiles: + clawhub: + scanners: + - id: skillspector + gate: + native: true + - id: clawscan-static + gate: + native: true + clawhub-static: + scanners: + - id: clawscan-static + gate: + native: true diff --git a/npm/clawscan-plugin/src/artifact.ts b/npm/clawscan-plugin/src/artifact.ts new file mode 100644 index 0000000..7ef6f76 --- /dev/null +++ b/npm/clawscan-plugin/src/artifact.ts @@ -0,0 +1,200 @@ +export type InstallFinding = { + ruleId: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; +}; + +export type BeforeInstallResult = { + findings?: InstallFinding[]; + block?: boolean; + blockReason?: string; +}; + +const MAX_GATE_RULES = 100; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanText(value: unknown, limit: number): string { + if (typeof value !== "string") { + return ""; + } + return value + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, limit); +} + +function cleanRuleSegment(value: unknown, fallback: string): string { + const cleaned = cleanText(value, 64) + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || fallback; +} + +function cleanFindingFile(value: unknown): string { + const raw = cleanText(value, 1_000).replaceAll("\\", "/"); + const segments = raw + .split("/") + .filter((segment) => segment !== "" && segment !== "." && segment !== "..") + .map((segment) => + segment + .replace(/[^a-zA-Z0-9._ -]+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(), + ) + .filter(Boolean); + return segments.join("/").slice(0, 240) || "."; +} + +function cleanFindingLine(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 1; + } + return Math.min(1_000_000, Math.max(1, Math.trunc(value))); +} + +function findingFromRule(rule: Record): InstallFinding | undefined { + if ( + typeof rule.scanner !== "string" || + typeof rule.rule !== "string" || + (rule.action !== "warn" && rule.action !== "block") + ) { + return undefined; + } + const scanner = cleanRuleSegment(rule.scanner, "unknown-scanner"); + const ruleName = cleanRuleSegment(rule.findingCode ?? rule.rule, "gate-rule"); + const title = + cleanText(rule.findingTitle, 240) || + `${cleanText(rule.scanner, 80)} fired ${cleanText(rule.rule, 80)}`; + const severity = cleanText(rule.findingSeverity, 40); + return { + ruleId: `clawscan/${scanner}/${ruleName}`, + severity: rule.action === "block" ? "critical" : "warn", + file: cleanFindingFile(rule.file), + line: cleanFindingLine(rule.line), + message: severity ? `${severity}: ${title}` : title, + }; +} + +function ruleReferencesAvailableScanner( + rule: Record, + scanners: Record, +): boolean { + return typeof rule.scanner === "string" && Object.hasOwn(scanners, rule.scanner); +} + +function blockForInvalidArtifact(reason: string): BeforeInstallResult { + return { + block: true, + blockReason: `ClawScan blocked installation: ${reason}`, + findings: [ + { + ruleId: "clawscan/artifact-invalid", + severity: "critical", + file: ".", + line: 1, + message: reason, + }, + ], + }; +} + +export function gateResultFromArtifact( + stdout: string, + requiredScanners: readonly string[], +): BeforeInstallResult | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return blockForInvalidArtifact("scanner output was not valid JSON"); + } + + if (!isRecord(parsed) || parsed.schemaVersion !== "clawscan-run-v1") { + return blockForInvalidArtifact("scanner output was not a clawscan-run-v1 artifact"); + } + if (!isRecord(parsed.scanners)) { + return blockForInvalidArtifact("scanner artifact did not contain scanner results"); + } + for (const scanner of requiredScanners) { + const result = parsed.scanners[scanner]; + if (!isRecord(result) || result.status !== "completed") { + return blockForInvalidArtifact(`required scanner ${scanner} did not complete`); + } + } + for (const [scanner, result] of Object.entries(parsed.scanners)) { + if (!isRecord(result) || result.status !== "completed") { + const scannerName = cleanRuleSegment(scanner, "unknown-scanner"); + return blockForInvalidArtifact(`scanner ${scannerName} did not complete`); + } + } + + if (!Array.isArray(parsed.gateRules)) { + return blockForInvalidArtifact("scanner artifact did not contain fired gate rules"); + } + if (parsed.gateRules.length > MAX_GATE_RULES) { + return blockForInvalidArtifact("scanner artifact contained too many fired gate rules"); + } + if (parsed.gate === "pass") { + if (parsed.gateRules.length !== 0) { + return blockForInvalidArtifact("pass artifact unexpectedly contained fired gate rules"); + } + return undefined; + } + if (parsed.gate === "warn") { + const findings: InstallFinding[] = []; + for (const rule of parsed.gateRules) { + if (!isRecord(rule) || rule.action !== "warn") { + return blockForInvalidArtifact("warn artifact contained an invalid fired gate rule"); + } + if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { + return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); + } + const finding = findingFromRule(rule); + if (!finding) { + return blockForInvalidArtifact("warn artifact contained an invalid fired gate rule"); + } + findings.push(finding); + } + if (findings.length === 0) { + return blockForInvalidArtifact("warn artifact did not contain a fired warning rule"); + } + return { findings }; + } + if (parsed.gate === "block") { + const findings: InstallFinding[] = []; + for (const rule of parsed.gateRules) { + if (!isRecord(rule)) { + return blockForInvalidArtifact("block artifact contained an invalid fired gate rule"); + } + if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { + return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); + } + const finding = findingFromRule(rule); + if (!finding) { + return blockForInvalidArtifact("block artifact contained an invalid fired gate rule"); + } + findings.push(finding); + } + const blockingMessages = findings + .filter((finding) => finding.severity === "critical") + .map((finding) => finding.message); + if (blockingMessages.length === 0) { + return blockForInvalidArtifact("block artifact did not contain a fired blocking rule"); + } + return { + block: true, + blockReason: cleanText( + `ClawScan gate blocked installation: ${blockingMessages.join("; ")}`, + 1_000, + ), + findings, + }; + } + return blockForInvalidArtifact("scanner artifact contained an unknown gate verdict"); +} diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts new file mode 100644 index 0000000..4e21794 --- /dev/null +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -0,0 +1,178 @@ +import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; + +const DOCKER_PROBE_TIMEOUT_MS = 5_000; +const SCAN_TIMEOUT_MS = 600_000; +const MAX_STDOUT_BYTES = 8 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; + +export type CommandOptions = { + timeoutMs: number; + env?: Record; + maxOutputBytes?: { + stdout: number; + stderr: number; + }; +}; + +export type CommandResult = { + code: number | null; + stdout: string; + stderr: string; + signal: string | null; + termination: "exit" | "timeout" | "no-output-timeout" | "signal"; +}; + +export type GateHandlerDependencies = { + runCommand: (argv: string[], options: CommandOptions) => Promise; + resolveBinaryPath: () => string; + resolveConfigPath: () => string; + resolveFallbackConfigPath?: () => string; + profile: string; +}; + +export type BeforeInstallEvent = { + sourcePath: string; +}; + +function commandSucceeded(result: CommandResult): boolean { + return result.code === 0 && result.signal === null && result.termination === "exit"; +} + +function cleanDiagnostic(message: string, limit = 600): string { + return message + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, limit); +} + +function failClosed(message: string): BeforeInstallResult { + const cleaned = cleanDiagnostic(message); + const reason = cleaned || "ClawScan could not complete the install-time scan"; + return { + block: true, + blockReason: `ClawScan blocked installation: ${reason}`, + findings: [ + { + ruleId: "clawscan/gate-failure", + severity: "critical", + file: ".", + line: 1, + message: reason, + }, + ], + }; +} + +function commandFailure(label: string, result: CommandResult): BeforeInstallResult { + if (result.termination === "timeout" || result.termination === "no-output-timeout") { + return failClosed(`${label} timed out`); + } + if (result.signal !== null || result.termination === "signal") { + const signal = cleanDiagnostic(result.signal ?? "unknown signal", 40); + return failClosed(`${label} was terminated by ${signal}`); + } + if (typeof result.code === "number") { + const stderr = cleanDiagnostic(result.stderr, 480); + return failClosed(`${label} exited with code ${result.code}${stderr ? `: ${stderr}` : ""}`); + } + return failClosed(`${label} failed without an exit code`); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + return typeof error.code === "string" ? error.code : undefined; +} + +const degradedFinding = { + ruleId: "clawscan/docker-unavailable", + severity: "warn" as const, + file: ".", + line: 1, + message: "Gate degraded: Docker unavailable; clawscan-static only.", +}; + +const scanCommandOptions: CommandOptions = { + timeoutMs: SCAN_TIMEOUT_MS, + env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + maxOutputBytes: { + stdout: MAX_STDOUT_BYTES, + stderr: MAX_STDERR_BYTES, + }, +}; + +export function createBeforeInstallHandler(dependencies: GateHandlerDependencies) { + return async (event: BeforeInstallEvent): Promise => { + try { + let dockerAvailable = false; + try { + const dockerProbe = await dependencies.runCommand(["docker", "info"], { + timeoutMs: DOCKER_PROBE_TIMEOUT_MS, + }); + dockerAvailable = commandSucceeded(dockerProbe); + } catch { + dockerAvailable = false; + } + const binaryPath = dependencies.resolveBinaryPath(); + if (!dockerAvailable) { + const fallbackConfigPath = + dependencies.resolveFallbackConfigPath?.() ?? dependencies.resolveConfigPath(); + const scan = await dependencies.runCommand( + [ + binaryPath, + event.sourcePath, + "--config", + fallbackConfigPath, + "--profile", + "clawhub-static", + "--scanner", + "clawscan-static", + "--sandbox", + "off", + "--json", + ], + scanCommandOptions, + ); + if (!commandSucceeded(scan)) { + const failure = commandFailure("ClawScan static fallback", scan); + return { + ...failure, + findings: [degradedFinding, ...(failure.findings ?? [])], + }; + } + const result = gateResultFromArtifact(scan.stdout, ["clawscan-static"]); + return { + ...result, + findings: [degradedFinding, ...(result?.findings ?? [])], + }; + } + + const configPath = dependencies.resolveConfigPath(); + const scan = await dependencies.runCommand( + [ + binaryPath, + event.sourcePath, + "--config", + configPath, + "--profile", + dependencies.profile, + "--sandbox", + "docker", + "--json", + ], + scanCommandOptions, + ); + if (!commandSucceeded(scan)) { + return commandFailure("ClawScan process", scan); + } + return gateResultFromArtifact(scan.stdout, ["skillspector", "clawscan-static"]); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return failClosed("ClawScan binary was not found"); + } + return failClosed("ClawScan could not start the install-time scan"); + } + }; +} diff --git a/npm/clawscan-plugin/src/register.ts b/npm/clawscan-plugin/src/register.ts new file mode 100644 index 0000000..3864c81 --- /dev/null +++ b/npm/clawscan-plugin/src/register.ts @@ -0,0 +1,57 @@ +import { + createBeforeInstallHandler, + type BeforeInstallEvent, + type CommandOptions, + type CommandResult, +} from "./gate-handler.ts"; +import type { BeforeInstallResult } from "./artifact.ts"; + +const DEFAULT_CONFIG_PATH = "profiles/clawhub.yml"; +const DEFAULT_PROFILE = "clawhub"; +const HOOK_TIMEOUT_MS = 615_000; + +export type RegisteredHandler = ( + event: BeforeInstallEvent, +) => Promise; + +export type GatePluginApi = { + pluginConfig?: Record; + resolvePath: (input: string) => string; + runtime: { + system: { + runCommandWithTimeout: (argv: string[], options: CommandOptions) => Promise; + }; + }; + on: ( + name: "before_install", + handler: RegisteredHandler, + options: { priority: number; timeoutMs: number }, + ) => void; +}; + +function configuredString( + pluginConfig: Record | undefined, + key: string, + fallback: string, +): string { + const value = pluginConfig?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +export function registerInstallGate(api: GatePluginApi, resolveBinaryPath: () => string): void { + const configPath = configuredString(api.pluginConfig, "configPath", DEFAULT_CONFIG_PATH); + const profile = configuredString(api.pluginConfig, "profile", DEFAULT_PROFILE); + const handler = createBeforeInstallHandler({ + resolveBinaryPath, + resolveConfigPath: () => api.resolvePath(configPath), + resolveFallbackConfigPath: () => api.resolvePath(DEFAULT_CONFIG_PATH), + profile, + runCommand: async (argv, options) => + await api.runtime.system.runCommandWithTimeout(argv, options), + }); + + api.on("before_install", handler, { + priority: 100, + timeoutMs: HOOK_TIMEOUT_MS, + }); +} diff --git a/npm/clawscan-plugin/test/artifact.test.ts b/npm/clawscan-plugin/test/artifact.test.ts new file mode 100644 index 0000000..0dde2af --- /dev/null +++ b/npm/clawscan-plugin/test/artifact.test.ts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { gateResultFromArtifact } from "../src/artifact.ts"; + +describe("gateResultFromArtifact", () => { + it("continues silently for a valid pass artifact", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, + }), + ["skillspector", "clawscan-static"], + ); + + assert.equal(result, undefined); + }); + + it("maps every fired warning to a structured non-blocking finding", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "warn", + gateRules: [ + { + scanner: "skillspector", + rule: "nativeFindingSeverity", + findingCode: "SS-101", + findingTitle: "Suspicious package script", + findingSeverity: "HIGH", + action: "warn", + }, + { + scanner: "clawscan-static", + rule: "nativeFinding", + findingCode: "prompt-injection", + findingTitle: "Prompt injection language", + findingSeverity: "high", + action: "warn", + }, + ], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, + }), + ["skillspector", "clawscan-static"], + ); + + assert.deepEqual(result, { + findings: [ + { + ruleId: "clawscan/skillspector/SS-101", + severity: "warn", + file: ".", + line: 1, + message: "HIGH: Suspicious package script", + }, + { + ruleId: "clawscan/clawscan-static/prompt-injection", + severity: "warn", + file: ".", + line: 1, + message: "high: Prompt injection language", + }, + ], + }); + }); + + it("maps a block artifact to an explicit block with its fired findings", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "block", + gateRules: [ + { + scanner: "skillspector", + rule: "nativeFindingSeverity", + findingCode: "SS-900", + findingTitle: "Credential theft behavior", + findingSeverity: "CRITICAL", + action: "block", + }, + ], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, + }), + ["skillspector", "clawscan-static"], + ); + + assert.deepEqual(result, { + block: true, + blockReason: "ClawScan gate blocked installation: CRITICAL: Credential theft behavior", + findings: [ + { + ruleId: "clawscan/skillspector/SS-900", + severity: "critical", + file: ".", + line: 1, + message: "CRITICAL: Credential theft behavior", + }, + ], + }); + }); + + it("bounds and sanitizes untrusted fired-rule text, file paths, and line numbers", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "warn", + gateRules: [ + { + scanner: "demo scanner\u0000", + rule: "nativeFinding", + findingCode: "odd rule/id", + findingTitle: `unsafe\u0000 title ${"x".repeat(400)}`, + findingSeverity: "HIGH", + file: "/../../private/\u0000token.ts", + line: 9_999_999, + action: "warn", + }, + ], + scanners: { + "demo scanner\u0000": { status: "completed" }, + }, + }), + ["demo scanner\u0000"], + ); + + assert.ok(result?.findings); + assert.equal(result.findings[0]?.ruleId, "clawscan/demo-scanner/odd-rule-id"); + assert.equal(result.findings[0]?.file, "private/token.ts"); + assert.equal(result.findings[0]?.line, 1_000_000); + assert.ok((result.findings[0]?.message.length ?? 0) <= 282); + assert.doesNotMatch(result.findings[0]?.message ?? "", /[\u0000-\u001f\u007f]/); + }); + + for (const fixture of [ + { + name: "malformed JSON", + stdout: "{", + requiredScanners: ["skillspector"], + }, + { + name: "an unknown gate verdict", + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "maybe", + gateRules: [], + scanners: { skillspector: { status: "completed" } }, + }), + requiredScanners: ["skillspector"], + }, + { + name: "a missing required scanner", + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: {}, + }), + requiredScanners: ["skillspector"], + }, + ...["skipped", "failed"].map((status) => ({ + name: `a ${status} required scanner`, + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { skillspector: { status, error: "untrusted scanner error" } }, + }), + requiredScanners: ["skillspector"], + })), + ]) { + it(`fails closed for ${fixture.name}`, () => { + const result = gateResultFromArtifact(fixture.stdout, fixture.requiredScanners); + + assert.equal(result?.block, true); + assert.match(result?.blockReason ?? "", /^ClawScan blocked installation:/); + assert.equal(result?.findings?.[0]?.severity, "critical"); + assert.doesNotMatch(result?.blockReason ?? "", /untrusted scanner error/); + }); + } + + it("fails closed when any additional profile scanner does not complete", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + "team-scanner": { status: "failed" }, + }, + }), + ["skillspector", "clawscan-static"], + ); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + "ClawScan blocked installation: scanner team-scanner did not complete", + ); + }); + + it("fails closed when a fired rule names a scanner outside the artifact", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "warn", + gateRules: [ + { + scanner: "invented-scanner", + rule: "nativeFinding", + action: "warn", + }, + ], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, + }), + ["skillspector", "clawscan-static"], + ); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + "ClawScan blocked installation: fired gate rule referenced an unavailable scanner", + ); + }); + + it("fails closed instead of returning an unbounded finding list", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "warn", + gateRules: Array.from({ length: 101 }, (_, index) => ({ + scanner: "clawscan-static", + rule: "nativeFinding", + findingCode: `finding-${index}`, + action: "warn", + })), + scanners: { + "clawscan-static": { status: "completed" }, + }, + }), + ["clawscan-static"], + ); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + "ClawScan blocked installation: scanner artifact contained too many fired gate rules", + ); + assert.equal(result?.findings?.length, 1); + }); +}); diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts new file mode 100644 index 0000000..4d78835 --- /dev/null +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createBeforeInstallHandler, + type CommandOptions, + type CommandResult, +} from "../src/gate-handler.ts"; + +type CommandCall = { + argv: string[]; + options: CommandOptions; +}; + +const passArtifact = JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, +}); + +function commandResult(overrides: Partial = {}): CommandResult { + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + termination: "exit", + ...overrides, + }; +} + +describe("createBeforeInstallHandler", () => { + it("runs the full shipped profile and continues silently for a pass artifact", async () => { + const calls: CommandCall[] = []; + const outputs = [commandResult(), commandResult({ stdout: passArtifact })]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async (argv, options) => { + calls.push({ argv, options }); + return outputs.shift() ?? commandResult(); + }, + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result, undefined); + assert.deepEqual(calls, [ + { + argv: ["docker", "info"], + options: { timeoutMs: 5_000 }, + }, + { + argv: [ + "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", + "/candidate/demo-skill", + "--config", + "/plugin/profiles/clawhub.yml", + "--profile", + "clawhub", + "--sandbox", + "docker", + "--json", + ], + options: { + timeoutMs: 600_000, + env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, + }, + }, + ]); + }); + + it("degrades visibly to the static scanner with exact safe arguments when Docker is unavailable", async () => { + const calls: CommandCall[] = []; + const staticPassArtifact = JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + "clawscan-static": { status: "completed" }, + }, + }); + const outputs = [ + commandResult({ code: 1, stderr: "daemon unavailable" }), + commandResult({ stdout: staticPassArtifact }), + ]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async (argv, options) => { + calls.push({ argv, options }); + return outputs.shift() ?? commandResult(); + }, + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.deepEqual(result, { + findings: [ + { + ruleId: "clawscan/docker-unavailable", + severity: "warn", + file: ".", + line: 1, + message: "Gate degraded: Docker unavailable; clawscan-static only.", + }, + ], + }); + assert.deepEqual(calls[1], { + argv: [ + "/plugin/bin/clawscan", + "/candidate/demo-skill", + "--config", + "/plugin/profiles/clawhub.yml", + "--profile", + "clawhub-static", + "--scanner", + "clawscan-static", + "--sandbox", + "off", + "--json", + ], + options: { + timeoutMs: 600_000, + env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, + }, + }); + }); + + it("treats a missing Docker command as degraded mode instead of skipping the scan", async () => { + let invocation = 0; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => { + invocation += 1; + if (invocation === 1) { + throw Object.assign(new Error("spawn docker ENOENT"), { code: "ENOENT" }); + } + return commandResult({ + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { "clawscan-static": { status: "completed" } }, + }), + }); + }, + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(invocation, 2); + assert.equal(result?.block, undefined); + assert.equal(result?.findings?.[0]?.ruleId, "clawscan/docker-unavailable"); + }); + + it("blocks with bounded sanitized stderr when the ClawScan process exits nonzero", async () => { + const outputs = [ + commandResult(), + commandResult({ + code: 17, + stderr: `bad\u0000 output ${"x".repeat(1_000)}`, + }), + ]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => outputs.shift() ?? commandResult(), + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result?.block, true); + assert.match( + result?.blockReason ?? "", + /^ClawScan blocked installation: ClawScan process exited with code 17: bad output/, + ); + assert.ok((result?.blockReason?.length ?? 0) <= 631); + assert.doesNotMatch(result?.blockReason ?? "", /[\u0000-\u001f\u007f]/); + }); + + it("blocks explicitly when the resolved ClawScan binary is missing", async () => { + let invocation = 0; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => { + invocation += 1; + if (invocation === 1) { + return commandResult(); + } + throw Object.assign(new Error("spawn /private/plugin/bin/clawscan ENOENT"), { + code: "ENOENT", + }); + }, + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + "ClawScan blocked installation: ClawScan binary was not found", + ); + assert.doesNotMatch(result?.blockReason ?? "", /\/private\/plugin/); + }); + + for (const fixture of [ + { + name: "timeout", + result: commandResult({ code: null, termination: "timeout" }), + reason: "ClawScan blocked installation: ClawScan process timed out", + }, + { + name: "signal", + result: commandResult({ code: null, signal: "SIGTERM", termination: "signal" }), + reason: "ClawScan blocked installation: ClawScan process was terminated by SIGTERM", + }, + ]) { + it(`blocks explicitly on process ${fixture.name}`, async () => { + const outputs = [commandResult(), fixture.result]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => outputs.shift() ?? commandResult(), + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result?.block, true); + assert.equal(result?.blockReason, fixture.reason); + }); + } + + it("blocks when the static fallback fails and keeps degraded mode visible", async () => { + const outputs = [ + commandResult({ code: 1 }), + commandResult({ code: 23, stderr: "static scan failed" }), + ]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => outputs.shift() ?? commandResult(), + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + "ClawScan blocked installation: ClawScan static fallback exited with code 23: static scan failed", + ); + assert.deepEqual( + result?.findings?.map((finding) => finding.ruleId), + ["clawscan/docker-unavailable", "clawscan/gate-failure"], + ); + }); +}); diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs new file mode 100644 index 0000000..763b7e2 --- /dev/null +++ b/npm/clawscan-plugin/test/package.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +describe("@openclaw/clawscan-plugin package", () => { + it("declares the install gate manifest and an exact matching binary dependency", async () => { + const packageJson = await readJson(join(packageRoot, "package.json")); + const manifest = await readJson(join(packageRoot, "openclaw.plugin.json")); + + assert.equal(packageJson.name, "@openclaw/clawscan-plugin"); + assert.equal(packageJson.version, "0.0.0-dev"); + assert.equal(packageJson.dependencies["@openclaw/clawscan"], packageJson.version); + assert.deepEqual(packageJson.openclaw.extensions, ["./index.ts"]); + assert.equal(packageJson.openclaw.install.npmSpec, "@openclaw/clawscan-plugin"); + assert.equal(manifest.id, "clawscan"); + assert.equal(manifest.activation.onStartup, false); + assert.deepEqual(manifest.activation.onCapabilities, ["hook"]); + assert.equal(manifest.enabledByDefault, undefined); + }); + + it("packs the manifest and profile without tests or install-time lifecycle bypasses", async () => { + const packageJson = await readJson(join(packageRoot, "package.json")); + const packed = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: packageRoot, + encoding: "utf8", + }); + assert.equal(packed.status, 0, packed.stderr); + const report = JSON.parse(packed.stdout)[0]; + const files = report.files.map((entry) => entry.path).sort(); + + assert.ok(files.includes("openclaw.plugin.json")); + assert.ok(files.includes("profiles/clawhub.yml")); + assert.ok(files.includes("index.ts")); + assert.ok(files.includes("src/gate-handler.ts")); + assert.equal( + files.some((path) => path.startsWith("test/")), + false, + ); + assert.equal(packageJson.scripts?.preinstall, undefined); + assert.equal(packageJson.scripts?.install, undefined); + assert.equal(packageJson.scripts?.postinstall, undefined); + }); + + it("keeps the entrypoint free of direct process-spawning imports", async () => { + const entrypoint = await readFile(join(packageRoot, "index.ts"), "utf8"); + const register = await readFile(join(packageRoot, "src", "register.ts"), "utf8"); + const handler = await readFile(join(packageRoot, "src", "gate-handler.ts"), "utf8"); + const forbiddenModule = ["node:child", "process"].join("_"); + + assert.doesNotMatch(entrypoint, new RegExp(forbiddenModule)); + assert.doesNotMatch(register, new RegExp(forbiddenModule)); + assert.doesNotMatch(handler, new RegExp(forbiddenModule)); + }); + + it("ships a no-judge profile with both required native gate scanners", async () => { + const profile = await readFile(join(packageRoot, "profiles", "clawhub.yml"), "utf8"); + + assert.match(profile, /id: skillspector/); + assert.match(profile, /id: clawscan-static/); + assert.match(profile, /native: true/); + assert.doesNotMatch(profile, /\bjudge:/); + }); +}); diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts new file mode 100644 index 0000000..07a863d --- /dev/null +++ b/npm/clawscan-plugin/test/registration.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { BeforeInstallEvent } from "../src/gate-handler.ts"; +import { registerInstallGate, type RegisteredHandler } from "../src/register.ts"; + +describe("registerInstallGate", () => { + it("registers a high-priority before_install hook with an explicit resolved config", async () => { + let registeredHandler: RegisteredHandler | undefined; + let resolvedPath = ""; + const commandCalls: string[][] = []; + registerInstallGate( + { + pluginConfig: { + configPath: "/trusted/custom.yml", + profile: "team-policy", + }, + resolvePath: (input) => { + resolvedPath = input; + return input; + }, + runtime: { + system: { + runCommandWithTimeout: async (argv) => { + commandCalls.push(argv); + if (argv[0] === "docker") { + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + termination: "exit", + }; + } + return { + code: 0, + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + skillspector: { status: "completed" }, + "clawscan-static": { status: "completed" }, + }, + }), + stderr: "", + signal: null, + termination: "exit", + }; + }, + }, + }, + on: (name, handler, options) => { + assert.equal(name, "before_install"); + assert.deepEqual(options, { priority: 100, timeoutMs: 615_000 }); + registeredHandler = handler; + }, + }, + () => "/plugin/bin/clawscan", + ); + + assert.ok(registeredHandler); + const result = await registeredHandler({ + sourcePath: "/untrusted/candidate", + } satisfies BeforeInstallEvent); + + assert.equal(result, undefined); + assert.equal(resolvedPath, "/trusted/custom.yml"); + assert.deepEqual(commandCalls[1], [ + "/plugin/bin/clawscan", + "/untrusted/candidate", + "--config", + "/trusted/custom.yml", + "--profile", + "team-policy", + "--sandbox", + "docker", + "--json", + ]); + }); +}); diff --git a/npm/clawscan/lib/resolve-binary.d.mts b/npm/clawscan/lib/resolve-binary.d.mts new file mode 100644 index 0000000..52eac7e --- /dev/null +++ b/npm/clawscan/lib/resolve-binary.d.mts @@ -0,0 +1,17 @@ +export type BinaryPlatform = "darwin" | "linux" | "win32"; +export type BinaryArchitecture = "arm64" | "x64"; + +export declare function platformKey(platform?: string, arch?: string): string; + +export declare function binaryFileName(platform?: string): string; + +export declare function resolveBinaryPath(options: { + packageRoot: string; + platform?: string; + arch?: string; +}): string; + +export declare function resolveBundledBinaryPath(options?: { + platform?: string; + arch?: string; +}): string; diff --git a/npm/clawscan/lib/resolve-binary.mjs b/npm/clawscan/lib/resolve-binary.mjs index ffd15dd..b3777af 100644 --- a/npm/clawscan/lib/resolve-binary.mjs +++ b/npm/clawscan/lib/resolve-binary.mjs @@ -1,4 +1,7 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); const supportedPlatforms = new Set([ "darwin-arm64", @@ -27,3 +30,10 @@ export function resolveBinaryPath({ }) { return join(packageRoot, "binaries", platformKey(platform, arch), binaryFileName(platform)); } + +export function resolveBundledBinaryPath({ + platform = process.platform, + arch = process.arch, +} = {}) { + return resolveBinaryPath({ packageRoot, platform, arch }); +} diff --git a/npm/clawscan/package.json b/npm/clawscan/package.json index e1b5dc1..c9eb696 100644 --- a/npm/clawscan/package.json +++ b/npm/clawscan/package.json @@ -2,16 +2,15 @@ "name": "@openclaw/clawscan", "version": "0.0.0-dev", "description": "Benchmarkable security scanner harness for agent skills.", + "homepage": "https://github.com/openclaw/clawscan#readme", + "bugs": { + "url": "https://github.com/openclaw/clawscan/issues" + }, "license": "MIT", - "type": "module", "repository": { "type": "git", "url": "git+https://github.com/openclaw/clawscan.git" }, - "homepage": "https://github.com/openclaw/clawscan#readme", - "bugs": { - "url": "https://github.com/openclaw/clawscan/issues" - }, "bin": { "clawscan": "./bin/clawscan.js" }, @@ -22,13 +21,20 @@ "LICENSE", "README.md" ], - "scripts": { - "test": "node --test test/*.test.mjs" + "type": "module", + "exports": { + "./resolve-binary": { + "types": "./lib/resolve-binary.d.mts", + "import": "./lib/resolve-binary.mjs" + } }, "publishConfig": { "access": "public", "provenance": true }, + "scripts": { + "test": "node --test test/*.test.mjs" + }, "engines": { "node": ">=18" } diff --git a/npm/clawscan/test/resolve-binary.test.mjs b/npm/clawscan/test/resolve-binary.test.mjs index 438d592..d012eb1 100644 --- a/npm/clawscan/test/resolve-binary.test.mjs +++ b/npm/clawscan/test/resolve-binary.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { binaryFileName, platformKey, resolveBinaryPath } from "../lib/resolve-binary.mjs"; +import { + binaryFileName, + platformKey, + resolveBinaryPath, + resolveBundledBinaryPath, +} from "../lib/resolve-binary.mjs"; describe("platformKey", () => { it("maps supported Node platform and arch pairs to package binary directories", () => { @@ -39,3 +44,12 @@ describe("resolveBinaryPath", () => { ); }); }); + +describe("resolveBundledBinaryPath", () => { + it("resolves the binary from the installed @openclaw/clawscan package", () => { + assert.match( + resolveBundledBinaryPath({ platform: "linux", arch: "x64" }), + /\/npm\/clawscan\/binaries\/linux-x64\/clawscan$/, + ); + }); +}); diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index c8c9485..da2a88c 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -19,7 +19,9 @@ export const packageTargets = [ ]; export function normalizePackageVersion(version) { - const match = String(version ?? "").trim().match(semverPattern); + const match = String(version ?? "") + .trim() + .match(semverPattern); if (!match) { throw new Error("Expected a semver npm package version or v-prefixed semver tag."); } @@ -42,6 +44,17 @@ export function binaryNameForTarget(target) { return target.goos === "windows" ? "clawscan.exe" : "clawscan"; } +export function preparePluginPackageJson(packageJson, packageVersion) { + return { + ...packageJson, + version: packageVersion, + dependencies: { + ...packageJson.dependencies, + "@openclaw/clawscan": packageVersion, + }, + }; +} + function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? repoRoot, @@ -52,7 +65,9 @@ function run(command, args, options = {}) { if (result.status !== 0) { const stderr = result.stderr ? `\n${result.stderr.trim()}` : ""; const stdout = result.stdout ? `\n${result.stdout.trim()}` : ""; - throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}${stderr}${stdout}`); + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status}${stderr}${stdout}`, + ); } return result; } @@ -88,17 +103,20 @@ function parseArgs(argv) { return options; } -async function stagePackage(options) { +async function stagePackages(options) { const packageVersion = normalizePackageVersion(options.version); const binaryVersion = binaryVersionFor(options.version); const releaseSha = run("git", ["rev-parse", "HEAD"]).stdout.trim(); const releaseCommit = run("git", ["rev-parse", "--short", "HEAD"]).stdout.trim(); const buildDate = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); const packageSource = join(repoRoot, "npm", "clawscan"); - const packageOut = join(options.outDir, "package"); + const pluginPackageSource = join(repoRoot, "npm", "clawscan-plugin"); + const packageOut = join(options.outDir, "clawscan-package"); + const pluginPackageOut = join(options.outDir, "clawscan-plugin-package"); await rm(options.outDir, { recursive: true, force: true }); await mkdir(packageOut, { recursive: true }); + await mkdir(pluginPackageOut, { recursive: true }); await cp(packageSource, packageOut, { recursive: true, filter: (source) => !source.includes(`${join("npm", "clawscan", "test")}`), @@ -108,80 +126,138 @@ async function stagePackage(options) { await cp(join(repoRoot, "README.md"), join(packageOut, "README.md")); await cp(join(repoRoot, "LICENSE"), join(packageOut, "LICENSE")); await chmod(join(packageOut, "bin", "clawscan.js"), 0o755); + await cp(pluginPackageSource, pluginPackageOut, { + recursive: true, + filter: (source) => !source.includes(`${join("npm", "clawscan-plugin", "test")}`), + }); + await rm(join(pluginPackageOut, "test"), { recursive: true, force: true }); + await cp(join(repoRoot, "LICENSE"), join(pluginPackageOut, "LICENSE")); const packageJsonPath = join(packageOut, "package.json"); const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); packageJson.version = packageVersion; await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + const pluginPackageJsonPath = join(pluginPackageOut, "package.json"); + const pluginPackageJson = preparePluginPackageJson( + JSON.parse(await readFile(pluginPackageJsonPath, "utf8")), + packageVersion, + ); + await writeFile(pluginPackageJsonPath, `${JSON.stringify(pluginPackageJson, null, 2)}\n`); const ldflags = `-s -w -X main.version=${binaryVersion} -X main.commit=${releaseCommit} -X main.date=${buildDate}`; for (const target of packageTargets) { const binaryDir = join(packageOut, "binaries", platformKeyForTarget(target)); await mkdir(binaryDir, { recursive: true }); - run("go", [ - "build", - "-trimpath", - "-ldflags", - ldflags, - "-o", - join(binaryDir, binaryNameForTarget(target)), - "github.com/openclaw/clawscan/cmd/clawscan", - ], { - env: { - ...process.env, - GOOS: target.goos, - GOARCH: target.goarch, - CGO_ENABLED: "0", + run( + "go", + [ + "build", + "-trimpath", + "-ldflags", + ldflags, + "-o", + join(binaryDir, binaryNameForTarget(target)), + "github.com/openclaw/clawscan/cmd/clawscan", + ], + { + env: { + ...process.env, + GOOS: target.goos, + GOARCH: target.goarch, + CGO_ENABLED: "0", + }, }, - }); + ); } await writeFile(join(options.outDir, "release-tag.txt"), `${binaryVersion}\n`); await writeFile(join(options.outDir, "release-sha.txt"), `${releaseSha}\n`); await writeFile(join(options.outDir, "package-version.txt"), `${packageVersion}\n`); - return { binaryVersion, packageOut, packageVersion, releaseSha }; + return { binaryVersion, packageOut, packageVersion, pluginPackageOut, releaseSha }; } async function packPackage(options, packageOut) { - const result = run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", options.outDir], { - cwd: packageOut, - }); + const result = run( + "npm", + ["pack", "--json", "--ignore-scripts", "--pack-destination", options.outDir], + { + cwd: packageOut, + }, + ); const parsed = JSON.parse(result.stdout); const first = Array.isArray(parsed) ? parsed[0] : undefined; if (!first?.filename) throw new Error("npm pack did not return a tarball filename."); return resolve(options.outDir, first.filename); } -async function smokePackage(tarballPath, binaryVersion) { +async function smokePackages( + clawscanTarballPath, + pluginTarballPath, + binaryVersion, + packageVersion, +) { const prefix = await mkdtemp(join(tmpdir(), "clawscan-npm-smoke-")); - run("npm", ["install", "-g", "--prefix", prefix, tarballPath]); - const binPath = process.platform === "win32" - ? join(prefix, "clawscan.cmd") - : join(prefix, "bin", "clawscan"); - const version = run(binPath, ["--version"]).stdout.trim(); - if (!version.includes(`clawscan ${binaryVersion} `)) { - throw new Error(`Unexpected clawscan --version output: ${version}`); + const pluginPrefix = await mkdtemp(join(tmpdir(), "clawscan-plugin-npm-smoke-")); + try { + run("npm", ["install", "-g", "--prefix", prefix, clawscanTarballPath]); + const binPath = + process.platform === "win32" ? join(prefix, "clawscan.cmd") : join(prefix, "bin", "clawscan"); + const version = run(binPath, ["--version"]).stdout.trim(); + if (!version.includes(`clawscan ${binaryVersion} `)) { + throw new Error(`Unexpected clawscan --version output: ${version}`); + } + const smoke = run(binPath, [ + join(repoRoot, "README.md"), + "--scanner", + "clawscan-static", + "--json", + ]); + JSON.parse(smoke.stdout); + + run("npm", ["install", "--prefix", pluginPrefix, clawscanTarballPath]); + run("npm", ["install", "--prefix", pluginPrefix, pluginTarballPath]); + const installedPluginRoot = join(pluginPrefix, "node_modules", "@openclaw", "clawscan-plugin"); + const installedPackageJson = JSON.parse( + await readFile(join(installedPluginRoot, "package.json"), "utf8"), + ); + if ( + installedPackageJson.version !== packageVersion || + installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion + ) { + throw new Error("Installed ClawScan plugin did not preserve its exact binary dependency."); + } + await readFile(join(installedPluginRoot, "openclaw.plugin.json"), "utf8"); + await readFile(join(installedPluginRoot, "profiles", "clawhub.yml"), "utf8"); + } finally { + await rm(prefix, { recursive: true, force: true }); + await rm(pluginPrefix, { recursive: true, force: true }); } - const smoke = run(binPath, [join(repoRoot, "README.md"), "--scanner", "clawscan-static", "--json"]); - JSON.parse(smoke.stdout); - await rm(prefix, { recursive: true, force: true }); } export async function main(argv = process.argv.slice(2)) { const options = parseArgs(argv); - const staged = await stagePackage(options); - let tarballPath = ""; + const staged = await stagePackages(options); + let clawscanTarballPath = ""; + let pluginTarballPath = ""; if (options.pack) { - tarballPath = await packPackage(options, staged.packageOut); + clawscanTarballPath = await packPackage(options, staged.packageOut); + pluginTarballPath = await packPackage(options, staged.pluginPackageOut); } if (options.smoke) { - await smokePackage(tarballPath, staged.binaryVersion); + await smokePackages( + clawscanTarballPath, + pluginTarballPath, + staged.binaryVersion, + staged.packageVersion, + ); } - console.log(`npm package staged: ${staged.packageOut}`); + console.log(`clawscan npm package staged: ${staged.packageOut}`); + console.log(`clawscan plugin npm package staged: ${staged.pluginPackageOut}`); console.log(`package version: ${staged.packageVersion}`); console.log(`binary version: ${staged.binaryVersion}`); - if (tarballPath) console.log(`npm tarball: ${tarballPath}`); + if (clawscanTarballPath) console.log(`clawscan npm tarball: ${clawscanTarballPath}`); + if (pluginTarballPath) console.log(`clawscan plugin npm tarball: ${pluginTarballPath}`); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index c27b402..e4db0f6 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -5,6 +5,7 @@ import { normalizePackageVersion, packageTargets, platformKeyForTarget, + preparePluginPackageJson, } from "./build-npm-package.mjs"; describe("normalizePackageVersion", () => { @@ -44,3 +45,23 @@ describe("package target mapping", () => { assert.equal(binaryNameForTarget({ goos: "windows", goarch: "amd64" }), "clawscan.exe"); }); }); + +describe("preparePluginPackageJson", () => { + it("pins the plugin and its binary dependency to the exact release version", () => { + assert.deepEqual( + preparePluginPackageJson( + { + name: "@openclaw/clawscan-plugin", + version: "0.0.0-dev", + dependencies: { "@openclaw/clawscan": "0.0.0-dev" }, + }, + "1.2.3", + ), + { + name: "@openclaw/clawscan-plugin", + version: "1.2.3", + dependencies: { "@openclaw/clawscan": "1.2.3" }, + }, + ); + }); +}); From 03e4fa681179cf5988c98a900cd6e374929ba3db Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:07:33 +1000 Subject: [PATCH 03/28] fix(plugin): harden install gate delivery --- .github/workflows/npm-release.yml | 37 +++++++++--- npm/clawscan-plugin/package.json | 8 +++ npm/clawscan-plugin/src/artifact.ts | 3 + npm/clawscan-plugin/src/gate-handler.ts | 27 ++++++++- npm/clawscan-plugin/test/artifact.test.ts | 17 ++++++ npm/clawscan-plugin/test/gate-handler.test.ts | 60 +++++++++++++++++++ npm/clawscan-plugin/test/package.test.mjs | 2 + scripts/build-npm-package.mjs | 39 +++++++++++- 8 files changed, 182 insertions(+), 11 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 5b4d133..44d2520 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -200,21 +200,44 @@ jobs: echo "clawscan_path=$CLAWSCAN_TARBALL" >> "$GITHUB_OUTPUT" echo "plugin_path=$PLUGIN_TARBALL" >> "$GITHUB_OUTPUT" - - name: Ensure versions are not already published + - name: Inspect npm publish state + id: publish_state run: | set -euo pipefail - for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do - if npm view "${package_name}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then - echo "${package_name}@${PACKAGE_VERSION} is already published on npm." - exit 1 + inspect_package() { + local package_name="$1" + local tarball_path="$2" + local output_name="$3" + local remote_integrity="" + if remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity 2>/dev/null)" && [[ -n "$remote_integrity" ]]; then + local local_integrity="" + # shellcheck disable=SC2016 + local_integrity="$(node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); + process.stdout.write(`sha512-${digest}`); + ' "$tarball_path")" + if [[ "$remote_integrity" != "$local_integrity" ]]; then + echo "${package_name}@${PACKAGE_VERSION} is already published with different contents." >&2 + exit 1 + fi + echo "${package_name}@${PACKAGE_VERSION} already matches the prepared tarball; skipping publish." + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + return fi - echo "Publishing ${package_name}@${PACKAGE_VERSION}" - done + echo "${package_name}@${PACKAGE_VERSION} is not published yet." + echo "${output_name}=true" >> "$GITHUB_OUTPUT" + } + inspect_package "@openclaw/clawscan" "${{ steps.publish_tarballs.outputs.clawscan_path }}" "clawscan_needed" + inspect_package "@openclaw/clawscan-plugin" "${{ steps.publish_tarballs.outputs.plugin_path }}" "plugin_needed" - name: Publish ClawScan binary package + if: steps.publish_state.outputs.clawscan_needed == 'true' run: npm publish "${{ steps.publish_tarballs.outputs.clawscan_path }}" --access public --provenance - name: Publish ClawScan OpenClaw plugin + if: steps.publish_state.outputs.plugin_needed == 'true' run: npm publish "${{ steps.publish_tarballs.outputs.plugin_path }}" --access public --provenance - name: Verify npm release metadata diff --git a/npm/clawscan-plugin/package.json b/npm/clawscan-plugin/package.json index 5e11e4a..b763e98 100644 --- a/npm/clawscan-plugin/package.json +++ b/npm/clawscan-plugin/package.json @@ -30,6 +30,14 @@ "dependencies": { "@openclaw/clawscan": "0.0.0-dev" }, + "peerDependencies": { + "openclaw": ">=2026.7.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, "engines": { "node": ">=22.22.3" }, diff --git a/npm/clawscan-plugin/src/artifact.ts b/npm/clawscan-plugin/src/artifact.ts index 7ef6f76..44c02ec 100644 --- a/npm/clawscan-plugin/src/artifact.ts +++ b/npm/clawscan-plugin/src/artifact.ts @@ -121,6 +121,9 @@ export function gateResultFromArtifact( if (!isRecord(parsed.scanners)) { return blockForInvalidArtifact("scanner artifact did not contain scanner results"); } + if (Object.keys(parsed.scanners).length === 0) { + return blockForInvalidArtifact("scanner artifact did not contain any scanner results"); + } for (const scanner of requiredScanners) { const result = parsed.scanners[scanner]; if (!isRecord(result) || result.status !== "completed") { diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index 4e21794..3292753 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -12,6 +12,8 @@ export type CommandOptions = { stdout: number; stderr: number; }; + outputCapture?: "head"; + terminateOnOutputLimit?: boolean; }; export type CommandResult = { @@ -20,6 +22,7 @@ export type CommandResult = { stderr: string; signal: string | null; termination: "exit" | "timeout" | "no-output-timeout" | "signal"; + outputLimitExceeded?: boolean; }; export type GateHandlerDependencies = { @@ -35,7 +38,12 @@ export type BeforeInstallEvent = { }; function commandSucceeded(result: CommandResult): boolean { - return result.code === 0 && result.signal === null && result.termination === "exit"; + return ( + result.code === 0 && + result.signal === null && + result.termination === "exit" && + result.outputLimitExceeded !== true + ); } function cleanDiagnostic(message: string, limit = 600): string { @@ -65,6 +73,9 @@ function failClosed(message: string): BeforeInstallResult { } function commandFailure(label: string, result: CommandResult): BeforeInstallResult { + if (result.outputLimitExceeded === true) { + return failClosed(`${label} exceeded its output limit`); + } if (result.termination === "timeout" || result.termination === "no-output-timeout") { return failClosed(`${label} timed out`); } @@ -101,8 +112,20 @@ const scanCommandOptions: CommandOptions = { stdout: MAX_STDOUT_BYTES, stderr: MAX_STDERR_BYTES, }, + outputCapture: "head", + terminateOnOutputLimit: true, }; +function requiredScannersForProfile(profile: string): readonly string[] { + if (profile === "clawhub") { + return ["skillspector", "clawscan-static"]; + } + if (profile === "clawhub-static") { + return ["clawscan-static"]; + } + return []; +} + export function createBeforeInstallHandler(dependencies: GateHandlerDependencies) { return async (event: BeforeInstallEvent): Promise => { try { @@ -167,7 +190,7 @@ export function createBeforeInstallHandler(dependencies: GateHandlerDependencies if (!commandSucceeded(scan)) { return commandFailure("ClawScan process", scan); } - return gateResultFromArtifact(scan.stdout, ["skillspector", "clawscan-static"]); + return gateResultFromArtifact(scan.stdout, requiredScannersForProfile(dependencies.profile)); } catch (error) { if (errorCode(error) === "ENOENT") { return failClosed("ClawScan binary was not found"); diff --git a/npm/clawscan-plugin/test/artifact.test.ts b/npm/clawscan-plugin/test/artifact.test.ts index 0dde2af..056bed0 100644 --- a/npm/clawscan-plugin/test/artifact.test.ts +++ b/npm/clawscan-plugin/test/artifact.test.ts @@ -210,6 +210,23 @@ describe("gateResultFromArtifact", () => { ); }); + it("fails closed when an artifact contains no scanner results", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: {}, + }), + [], + ); + + assert.equal( + result?.blockReason, + "ClawScan blocked installation: scanner artifact did not contain any scanner results", + ); + }); + it("fails closed when a fired rule names a scanner outside the artifact", () => { const result = gateResultFromArtifact( JSON.stringify({ diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index 4d78835..74cd0a5 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -70,6 +70,8 @@ describe("createBeforeInstallHandler", () => { timeoutMs: 600_000, env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, + outputCapture: "head", + terminateOnOutputLimit: true, }, }, ]); @@ -130,10 +132,43 @@ describe("createBeforeInstallHandler", () => { timeoutMs: 600_000, env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, + outputCapture: "head", + terminateOnOutputLimit: true, }, }); }); + it("validates the scanners selected by a shipped non-default profile", async () => { + const calls: CommandCall[] = []; + const outputs = [ + commandResult(), + commandResult({ + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { + "clawscan-static": { status: "completed" }, + }, + }), + }), + ]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub-static", + runCommand: async (argv, options) => { + calls.push({ argv, options }); + return outputs.shift() ?? commandResult(); + }, + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal(result, undefined); + assert.ok(calls[1]?.argv.includes("clawhub-static")); + }); + it("treats a missing Docker command as degraded mode instead of skipping the scan", async () => { let invocation = 0; const handler = createBeforeInstallHandler({ @@ -268,4 +303,29 @@ describe("createBeforeInstallHandler", () => { ["clawscan/docker-unavailable", "clawscan/gate-failure"], ); }); + + it("blocks explicitly when scanner output exceeds the host capture limit", async () => { + const outputs = [ + commandResult(), + commandResult({ + code: null, + signal: "SIGTERM", + termination: "signal", + outputLimitExceeded: true, + }), + ]; + const handler = createBeforeInstallHandler({ + resolveBinaryPath: () => "/plugin/bin/clawscan", + resolveConfigPath: () => "/plugin/profiles/clawhub.yml", + profile: "clawhub", + runCommand: async () => outputs.shift() ?? commandResult(), + }); + + const result = await handler({ sourcePath: "/candidate/demo-skill" }); + + assert.equal( + result?.blockReason, + "ClawScan blocked installation: ClawScan process exceeded its output limit", + ); + }); }); diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs index 763b7e2..0d5c818 100644 --- a/npm/clawscan-plugin/test/package.test.mjs +++ b/npm/clawscan-plugin/test/package.test.mjs @@ -19,6 +19,8 @@ describe("@openclaw/clawscan-plugin package", () => { assert.equal(packageJson.name, "@openclaw/clawscan-plugin"); assert.equal(packageJson.version, "0.0.0-dev"); assert.equal(packageJson.dependencies["@openclaw/clawscan"], packageJson.version); + assert.equal(packageJson.peerDependencies.openclaw, ">=2026.7.2"); + assert.equal(packageJson.peerDependenciesMeta.openclaw.optional, true); assert.deepEqual(packageJson.openclaw.extensions, ["./index.ts"]); assert.equal(packageJson.openclaw.install.npmSpec, "@openclaw/clawscan-plugin"); assert.equal(manifest.id, "clawscan"); diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index da2a88c..91f9dae 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -223,12 +223,47 @@ async function smokePackages( ); if ( installedPackageJson.version !== packageVersion || - installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion + installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion || + installedPackageJson.peerDependencies?.openclaw !== ">=2026.7.2" ) { - throw new Error("Installed ClawScan plugin did not preserve its exact binary dependency."); + throw new Error("Installed ClawScan plugin did not preserve its host and binary contracts."); } await readFile(join(installedPluginRoot, "openclaw.plugin.json"), "utf8"); await readFile(join(installedPluginRoot, "profiles", "clawhub.yml"), "utf8"); + + const hostPackageRoot = join(pluginPrefix, "node_modules", "openclaw"); + await mkdir(join(hostPackageRoot, "plugin-sdk"), { recursive: true }); + await writeFile( + join(hostPackageRoot, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + version: "2026.7.2", + type: "module", + exports: { + "./plugin-sdk/plugin-entry": "./plugin-sdk/plugin-entry.mjs", + }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(hostPackageRoot, "plugin-sdk", "plugin-entry.mjs"), + "export const definePluginEntry = (definition) => definition;\n", + ); + const entrypointSmokeRoot = join(pluginPrefix, "packed-entrypoint-smoke"); + await cp(installedPluginRoot, entrypointSmokeRoot, { recursive: true }); + const entrypointUrl = pathToFileURL(join(entrypointSmokeRoot, "index.ts")).href; + run( + "node", + [ + "--input-type=module", + "--eval", + `const plugin = (await import(${JSON.stringify(entrypointUrl)})).default; if (plugin?.id !== "clawscan" || typeof plugin?.register !== "function") throw new Error("packed plugin entrypoint did not load");`, + ], + { cwd: pluginPrefix }, + ); } finally { await rm(prefix, { recursive: true, force: true }); await rm(pluginPrefix, { recursive: true, force: true }); From f19aa96112151b0cb55a35be55a403a2576eaa64 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:19:14 +1000 Subject: [PATCH 04/28] fix(plugin): bind gate checks to trusted config --- .github/workflows/npm-release.yml | 33 +++++++++-------- npm/clawscan-plugin/src/gate-handler.ts | 35 ++++--------------- npm/clawscan-plugin/src/register.ts | 14 ++++++++ npm/clawscan-plugin/test/gate-handler.test.ts | 3 ++ npm/clawscan-plugin/test/registration.test.ts | 9 ++--- 5 files changed, 44 insertions(+), 50 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 44d2520..c55765f 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -206,31 +206,30 @@ jobs: set -euo pipefail inspect_package() { local package_name="$1" - local tarball_path="$2" - local output_name="$3" - local remote_integrity="" - if remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity 2>/dev/null)" && [[ -n "$remote_integrity" ]]; then - local local_integrity="" - # shellcheck disable=SC2016 - local_integrity="$(node --input-type=module -e ' - import { createHash } from "node:crypto"; - import { readFileSync } from "node:fs"; - const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); - process.stdout.write(`sha512-${digest}`); - ' "$tarball_path")" - if [[ "$remote_integrity" != "$local_integrity" ]]; then - echo "${package_name}@${PACKAGE_VERSION} is already published with different contents." >&2 + local output_name="$2" + local published_version="" + if published_version="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then + if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then + echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 exit 1 fi - echo "${package_name}@${PACKAGE_VERSION} already matches the prepared tarball; skipping publish." + if [[ "$package_name" == "@openclaw/clawscan-plugin" ]]; then + local binary_dependency="" + binary_dependency="$(npm view "${package_name}@${PACKAGE_VERSION}" 'dependencies.@openclaw/clawscan')" + if [[ "$binary_dependency" != "$PACKAGE_VERSION" ]]; then + echo "${package_name}@${PACKAGE_VERSION} does not depend on the matching ClawScan version." >&2 + exit 1 + fi + fi + echo "${package_name}@${PACKAGE_VERSION} is already published with valid release metadata; skipping publish." echo "${output_name}=false" >> "$GITHUB_OUTPUT" return fi echo "${package_name}@${PACKAGE_VERSION} is not published yet." echo "${output_name}=true" >> "$GITHUB_OUTPUT" } - inspect_package "@openclaw/clawscan" "${{ steps.publish_tarballs.outputs.clawscan_path }}" "clawscan_needed" - inspect_package "@openclaw/clawscan-plugin" "${{ steps.publish_tarballs.outputs.plugin_path }}" "plugin_needed" + inspect_package "@openclaw/clawscan" "clawscan_needed" + inspect_package "@openclaw/clawscan-plugin" "plugin_needed" - name: Publish ClawScan binary package if: steps.publish_state.outputs.clawscan_needed == 'true' diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index 3292753..ff79bcf 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -1,3 +1,4 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; const DOCKER_PROBE_TIMEOUT_MS = 5_000; @@ -5,25 +6,10 @@ const SCAN_TIMEOUT_MS = 600_000; const MAX_STDOUT_BYTES = 8 * 1024 * 1024; const MAX_STDERR_BYTES = 64 * 1024; -export type CommandOptions = { - timeoutMs: number; - env?: Record; - maxOutputBytes?: { - stdout: number; - stderr: number; - }; - outputCapture?: "head"; - terminateOnOutputLimit?: boolean; -}; +type HostRunCommand = OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"]; -export type CommandResult = { - code: number | null; - stdout: string; - stderr: string; - signal: string | null; - termination: "exit" | "timeout" | "no-output-timeout" | "signal"; - outputLimitExceeded?: boolean; -}; +export type CommandOptions = Exclude[1], number>; +export type CommandResult = Awaited>; export type GateHandlerDependencies = { runCommand: (argv: string[], options: CommandOptions) => Promise; @@ -31,6 +17,7 @@ export type GateHandlerDependencies = { resolveConfigPath: () => string; resolveFallbackConfigPath?: () => string; profile: string; + requiredScanners?: readonly string[]; }; export type BeforeInstallEvent = { @@ -116,16 +103,6 @@ const scanCommandOptions: CommandOptions = { terminateOnOutputLimit: true, }; -function requiredScannersForProfile(profile: string): readonly string[] { - if (profile === "clawhub") { - return ["skillspector", "clawscan-static"]; - } - if (profile === "clawhub-static") { - return ["clawscan-static"]; - } - return []; -} - export function createBeforeInstallHandler(dependencies: GateHandlerDependencies) { return async (event: BeforeInstallEvent): Promise => { try { @@ -190,7 +167,7 @@ export function createBeforeInstallHandler(dependencies: GateHandlerDependencies if (!commandSucceeded(scan)) { return commandFailure("ClawScan process", scan); } - return gateResultFromArtifact(scan.stdout, requiredScannersForProfile(dependencies.profile)); + return gateResultFromArtifact(scan.stdout, dependencies.requiredScanners ?? []); } catch (error) { if (errorCode(error) === "ENOENT") { return failClosed("ClawScan binary was not found"); diff --git a/npm/clawscan-plugin/src/register.ts b/npm/clawscan-plugin/src/register.ts index 3864c81..3143e07 100644 --- a/npm/clawscan-plugin/src/register.ts +++ b/npm/clawscan-plugin/src/register.ts @@ -38,6 +38,19 @@ function configuredString( return typeof value === "string" && value.trim() ? value.trim() : fallback; } +function requiredScannersForShippedConfig(configPath: string, profile: string): readonly string[] { + if (configPath !== DEFAULT_CONFIG_PATH) { + return []; + } + if (profile === "clawhub") { + return ["skillspector", "clawscan-static"]; + } + if (profile === "clawhub-static") { + return ["clawscan-static"]; + } + return []; +} + export function registerInstallGate(api: GatePluginApi, resolveBinaryPath: () => string): void { const configPath = configuredString(api.pluginConfig, "configPath", DEFAULT_CONFIG_PATH); const profile = configuredString(api.pluginConfig, "profile", DEFAULT_PROFILE); @@ -46,6 +59,7 @@ export function registerInstallGate(api: GatePluginApi, resolveBinaryPath: () => resolveConfigPath: () => api.resolvePath(configPath), resolveFallbackConfigPath: () => api.resolvePath(DEFAULT_CONFIG_PATH), profile, + requiredScanners: requiredScannersForShippedConfig(configPath, profile), runCommand: async (argv, options) => await api.runtime.system.runCommandWithTimeout(argv, options), }); diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index 74cd0a5..9c6088d 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -27,6 +27,7 @@ function commandResult(overrides: Partial = {}): CommandResult { stdout: "", stderr: "", signal: null, + killed: false, termination: "exit", ...overrides, }; @@ -40,6 +41,7 @@ describe("createBeforeInstallHandler", () => { resolveBinaryPath: () => "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", resolveConfigPath: () => "/plugin/profiles/clawhub.yml", profile: "clawhub", + requiredScanners: ["skillspector", "clawscan-static"], runCommand: async (argv, options) => { calls.push({ argv, options }); return outputs.shift() ?? commandResult(); @@ -157,6 +159,7 @@ describe("createBeforeInstallHandler", () => { resolveBinaryPath: () => "/plugin/bin/clawscan", resolveConfigPath: () => "/plugin/profiles/clawhub.yml", profile: "clawhub-static", + requiredScanners: ["clawscan-static"], runCommand: async (argv, options) => { calls.push({ argv, options }); return outputs.shift() ?? commandResult(); diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts index 07a863d..89c9a91 100644 --- a/npm/clawscan-plugin/test/registration.test.ts +++ b/npm/clawscan-plugin/test/registration.test.ts @@ -12,7 +12,7 @@ describe("registerInstallGate", () => { { pluginConfig: { configPath: "/trusted/custom.yml", - profile: "team-policy", + profile: "clawhub", }, resolvePath: (input) => { resolvedPath = input; @@ -28,6 +28,7 @@ describe("registerInstallGate", () => { stdout: "", stderr: "", signal: null, + killed: false, termination: "exit", }; } @@ -38,12 +39,12 @@ describe("registerInstallGate", () => { gate: "pass", gateRules: [], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + "team-scanner": { status: "completed" }, }, }), stderr: "", signal: null, + killed: false, termination: "exit", }; }, @@ -71,7 +72,7 @@ describe("registerInstallGate", () => { "--config", "/trusted/custom.yml", "--profile", - "team-policy", + "clawhub", "--sandbox", "docker", "--json", From c6b7f3fb149f6a97f38f0297c550c7c5b5e94040 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:32:21 +1000 Subject: [PATCH 05/28] fix(plugin): degrade safely on Windows --- npm/clawscan-plugin/README.md | 7 +++-- npm/clawscan-plugin/src/gate-handler.ts | 20 ++++++++----- npm/clawscan-plugin/test/gate-handler.test.ts | 30 ++++++++++++++++++- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/npm/clawscan-plugin/README.md b/npm/clawscan-plugin/README.md index 8db371f..f0bee9d 100644 --- a/npm/clawscan-plugin/README.md +++ b/npm/clawscan-plugin/README.md @@ -20,9 +20,10 @@ sandbox. This no-LLM mode does not send source files to a model provider, but SkillSpector still sends dependency names to [OSV.dev](https://osv.dev/) for CVE lookups. -If Docker is unavailable, the plugin visibly reports that the gate is degraded -and runs only `clawscan-static` with the sandbox disabled. This fallback is a -small static tripwire, not equivalent protection. +If Docker mode is unavailable on the host, including on native Windows, the +plugin visibly reports that the gate is degraded and runs only +`clawscan-static` with the sandbox disabled. This fallback is a small static +tripwire, not equivalent protection. The plugin accepts only an explicit `configPath` and `profile`. Relative config paths resolve from the plugin directory; the untrusted candidate directory is diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index ff79bcf..b48b838 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -1,3 +1,4 @@ +import process from "node:process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; @@ -12,6 +13,7 @@ export type CommandOptions = Exclude[1], number>; export type CommandResult = Awaited>; export type GateHandlerDependencies = { + platform?: NodeJS.Platform; runCommand: (argv: string[], options: CommandOptions) => Promise; resolveBinaryPath: () => string; resolveConfigPath: () => string; @@ -89,7 +91,7 @@ const degradedFinding = { severity: "warn" as const, file: ".", line: 1, - message: "Gate degraded: Docker unavailable; clawscan-static only.", + message: "Gate degraded: Docker mode unavailable on this host; clawscan-static only.", }; const scanCommandOptions: CommandOptions = { @@ -107,13 +109,15 @@ export function createBeforeInstallHandler(dependencies: GateHandlerDependencies return async (event: BeforeInstallEvent): Promise => { try { let dockerAvailable = false; - try { - const dockerProbe = await dependencies.runCommand(["docker", "info"], { - timeoutMs: DOCKER_PROBE_TIMEOUT_MS, - }); - dockerAvailable = commandSucceeded(dockerProbe); - } catch { - dockerAvailable = false; + if ((dependencies.platform ?? process.platform) !== "win32") { + try { + const dockerProbe = await dependencies.runCommand(["docker", "info"], { + timeoutMs: DOCKER_PROBE_TIMEOUT_MS, + }); + dockerAvailable = commandSucceeded(dockerProbe); + } catch { + dockerAvailable = false; + } } const binaryPath = dependencies.resolveBinaryPath(); if (!dockerAvailable) { diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index 9c6088d..b2d569f 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -112,7 +112,7 @@ describe("createBeforeInstallHandler", () => { severity: "warn", file: ".", line: 1, - message: "Gate degraded: Docker unavailable; clawscan-static only.", + message: "Gate degraded: Docker mode unavailable on this host; clawscan-static only.", }, ], }); @@ -201,6 +201,34 @@ describe("createBeforeInstallHandler", () => { assert.equal(result?.findings?.[0]?.ruleId, "clawscan/docker-unavailable"); }); + it("uses the static degraded path on Windows even when Docker may be installed", async () => { + const calls: CommandCall[] = []; + const handler = createBeforeInstallHandler({ + platform: "win32", + resolveBinaryPath: () => "C:\\plugin\\clawscan.exe", + resolveConfigPath: () => "C:\\plugin\\profiles\\clawhub.yml", + profile: "clawhub", + runCommand: async (argv, options) => { + calls.push({ argv, options }); + return commandResult({ + stdout: JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { "clawscan-static": { status: "completed" } }, + }), + }); + }, + }); + + const result = await handler({ sourcePath: "C:\\candidate\\demo-skill" }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.argv[0], "C:\\plugin\\clawscan.exe"); + assert.equal(calls[0]?.argv.includes("docker"), false); + assert.equal(result?.findings?.[0]?.ruleId, "clawscan/docker-unavailable"); + }); + it("blocks with bounded sanitized stderr when the ClawScan process exits nonzero", async () => { const outputs = [ commandResult(), From 0964527798a6da1d460f61717ba69796fccd1c16 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:46:23 +1000 Subject: [PATCH 06/28] fix(release): make npm promotion reproducible --- .github/workflows/npm-release.yml | 21 ++++++++++++++++++--- scripts/build-npm-package.mjs | 14 ++++++++++++-- scripts/build-npm-package.test.mjs | 11 +++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index c55765f..f17aca4 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -206,13 +206,28 @@ jobs: set -euo pipefail inspect_package() { local package_name="$1" - local output_name="$2" + local tarball_path="$2" + local output_name="$3" local published_version="" if published_version="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 exit 1 fi + local remote_integrity="" + local local_integrity="" + remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity)" + # shellcheck disable=SC2016 + local_integrity="$(node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); + process.stdout.write(`sha512-${digest}`); + ' "$tarball_path")" + if [[ "$remote_integrity" != "$local_integrity" ]]; then + echo "${package_name}@${PACKAGE_VERSION} does not match the prepared release tarball." >&2 + exit 1 + fi if [[ "$package_name" == "@openclaw/clawscan-plugin" ]]; then local binary_dependency="" binary_dependency="$(npm view "${package_name}@${PACKAGE_VERSION}" 'dependencies.@openclaw/clawscan')" @@ -228,8 +243,8 @@ jobs: echo "${package_name}@${PACKAGE_VERSION} is not published yet." echo "${output_name}=true" >> "$GITHUB_OUTPUT" } - inspect_package "@openclaw/clawscan" "clawscan_needed" - inspect_package "@openclaw/clawscan-plugin" "plugin_needed" + inspect_package "@openclaw/clawscan" "${{ steps.publish_tarballs.outputs.clawscan_path }}" "clawscan_needed" + inspect_package "@openclaw/clawscan-plugin" "${{ steps.publish_tarballs.outputs.plugin_path }}" "plugin_needed" - name: Publish ClawScan binary package if: steps.publish_state.outputs.clawscan_needed == 'true' diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 91f9dae..e89b823 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -28,6 +28,14 @@ export function normalizePackageVersion(version) { return match[1]; } +export function normalizeBuildDate(value) { + const parsed = new Date(String(value ?? "").trim()); + if (Number.isNaN(parsed.valueOf())) { + throw new Error("Expected a valid commit timestamp for the package build date."); + } + return parsed.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + export function binaryVersionFor(version) { const trimmed = String(version ?? "").trim(); const packageVersion = normalizePackageVersion(trimmed); @@ -108,10 +116,12 @@ async function stagePackages(options) { const binaryVersion = binaryVersionFor(options.version); const releaseSha = run("git", ["rev-parse", "HEAD"]).stdout.trim(); const releaseCommit = run("git", ["rev-parse", "--short", "HEAD"]).stdout.trim(); - const buildDate = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const buildDate = normalizeBuildDate( + run("git", ["show", "-s", "--format=%cI", "HEAD"]).stdout.trim(), + ); const packageSource = join(repoRoot, "npm", "clawscan"); const pluginPackageSource = join(repoRoot, "npm", "clawscan-plugin"); - const packageOut = join(options.outDir, "clawscan-package"); + const packageOut = join(options.outDir, "package"); const pluginPackageOut = join(options.outDir, "clawscan-plugin-package"); await rm(options.outDir, { recursive: true, force: true }); diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index e4db0f6..e390215 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { binaryNameForTarget, normalizePackageVersion, + normalizeBuildDate, packageTargets, platformKeyForTarget, preparePluginPackageJson, @@ -26,6 +27,16 @@ describe("normalizePackageVersion", () => { }); }); +describe("normalizeBuildDate", () => { + it("derives a stable UTC build date from commit metadata", () => { + assert.equal(normalizeBuildDate("2026-07-28T12:34:56+10:00"), "2026-07-28T02:34:56Z"); + }); + + it("rejects invalid commit timestamps", () => { + assert.throws(() => normalizeBuildDate("not-a-date"), /valid commit timestamp/); + }); +}); + describe("package target mapping", () => { it("maps Go release targets to npm binary directories", () => { assert.deepEqual( From 7a1490c316c4cc4728ab4ac5443424a87e0cb7e8 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:58:33 +1000 Subject: [PATCH 07/28] fix(plugin): terminate scanner process trees --- npm/clawscan-plugin/src/gate-handler.ts | 1 + npm/clawscan-plugin/test/gate-handler.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index b48b838..dcb19a0 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -97,6 +97,7 @@ const degradedFinding = { const scanCommandOptions: CommandOptions = { timeoutMs: SCAN_TIMEOUT_MS, env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + killProcessTree: true, maxOutputBytes: { stdout: MAX_STDOUT_BYTES, stderr: MAX_STDERR_BYTES, diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index b2d569f..4d1c7b9 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -71,6 +71,7 @@ describe("createBeforeInstallHandler", () => { options: { timeoutMs: 600_000, env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + killProcessTree: true, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, outputCapture: "head", terminateOnOutputLimit: true, @@ -133,6 +134,7 @@ describe("createBeforeInstallHandler", () => { options: { timeoutMs: 600_000, env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + killProcessTree: true, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, outputCapture: "head", terminateOnOutputLimit: true, From d70241d89a32f3bf1b31f167a985e4aeb2a7cd45 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:22:32 +1000 Subject: [PATCH 08/28] fix(release): publish plugin from version tags --- .../workflows/clawscan-plugin-self-scan.yml | 14 --- .github/workflows/release.yml | 96 ++++++++++++++----- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/.github/workflows/clawscan-plugin-self-scan.yml b/.github/workflows/clawscan-plugin-self-scan.yml index 0dd7aeb..31e9a9a 100644 --- a/.github/workflows/clawscan-plugin-self-scan.yml +++ b/.github/workflows/clawscan-plugin-self-scan.yml @@ -1,20 +1,6 @@ name: ClawScan Plugin Self-Scan on: - pull_request: - paths: - - ".github/workflows/clawscan-plugin-self-scan.yml" - - "cmd/clawscan/**" - - "internal/**" - - "npm/clawscan-plugin/**" - push: - branches: - - main - paths: - - ".github/workflows/clawscan-plugin-self-scan.yml" - - "cmd/clawscan/**" - - "internal/**" - - "npm/clawscan-plugin/**" workflow_dispatch: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a625c4..900b0d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: --generate-notes publish-npm: - name: Publish @openclaw/clawscan + name: Publish ClawScan npm packages runs-on: ubuntu-latest needs: build if: github.event_name == 'push' || inputs.publish @@ -133,7 +133,9 @@ jobs: const { readFileSync } = require("node:fs"); const pkg = JSON.parse(readFileSync("npm/clawscan/package.json", "utf8")); + const pluginPkg = JSON.parse(readFileSync("npm/clawscan-plugin/package.json", "utf8")); const expectedName = "@openclaw/clawscan"; + const expectedPluginName = "@openclaw/clawscan-plugin"; const expectedRepo = "https://github.com/openclaw/clawscan"; const repository = typeof pkg.repository === "string" ? pkg.repository @@ -155,6 +157,15 @@ jobs: if (pkg.private === true) { errors.push("package must not be private"); } + if (pluginPkg.name !== expectedPluginName) { + errors.push(`plugin package name must be ${expectedPluginName}; found ${pluginPkg.name ?? ""}`); + } + if (pluginPkg.dependencies?.[expectedName] !== pluginPkg.version) { + errors.push(`plugin must depend on the exact matching ${expectedName} version`); + } + if (pluginPkg.private === true) { + errors.push("plugin package must not be private"); + } if (errors.length > 0) { for (const error of errors) console.error(error); process.exit(1); @@ -182,37 +193,70 @@ jobs: execFileSync("git", ["merge-base", "--is-ancestor", releaseSha, "origin/main"]); NODE - - name: Ensure version is unpublished + - name: Check npm packages + run: | + node --test npm/clawscan/test/*.test.mjs + node --test npm/clawscan-plugin/test/*.test.mjs npm/clawscan-plugin/test/*.test.ts + node --test scripts/build-npm-package.test.mjs + node scripts/build-npm-package.mjs --version "${{ needs.build.outputs.version }}" --pack --smoke + + - name: Inspect npm publish state + id: publish_state env: RELEASE_TAG: ${{ needs.build.outputs.version }} run: | set -euo pipefail - package_name="$(node -p "require('./npm/clawscan/package.json').name")" package_version="$(node --input-type=module -e 'import { normalizePackageVersion } from "./scripts/build-npm-package.mjs"; process.stdout.write(normalizePackageVersion(process.env.RELEASE_TAG));')" - set +e - output="$(npm view "${package_name}@${package_version}" version 2>&1)" - status=$? - set -e - if [ "${status}" -eq 0 ]; then - echo "${package_name}@${package_version} is already published." - exit 1 - fi - if ! printf '%s\n' "${output}" | grep -q "E404"; then - printf '%s\n' "${output}" >&2 - exit "${status}" - fi - - - name: Check npm package + inspect_package() { + local package_name="$1" + local tarball_path="$2" + local output_name="$3" + local published_version="" + if published_version="$(npm view "${package_name}@${package_version}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then + local remote_integrity="" + local local_integrity="" + remote_integrity="$(npm view "${package_name}@${package_version}" dist.integrity)" + # shellcheck disable=SC2016 + local_integrity="$(node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); + process.stdout.write(`sha512-${digest}`); + ' "$tarball_path")" + if [[ "$published_version" != "$package_version" || "$remote_integrity" != "$local_integrity" ]]; then + echo "${package_name}@${package_version} does not match the prepared release tarball." >&2 + exit 1 + fi + echo "${package_name}@${package_version} already matches the prepared release tarball; skipping publish." + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + return + fi + echo "${output_name}=true" >> "$GITHUB_OUTPUT" + } + inspect_package \ + "@openclaw/clawscan" \ + "dist/npm/openclaw-clawscan-${package_version}.tgz" \ + "clawscan_needed" + inspect_package \ + "@openclaw/clawscan-plugin" \ + "dist/npm/openclaw-clawscan-plugin-${package_version}.tgz" \ + "plugin_needed" + echo "package_version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Publish ClawScan binary package + if: steps.publish_state.outputs.clawscan_needed == 'true' + run: npm publish "dist/npm/openclaw-clawscan-${{ steps.publish_state.outputs.package_version }}.tgz" --access public --provenance + + - name: Publish ClawScan OpenClaw plugin + if: steps.publish_state.outputs.plugin_needed == 'true' + run: npm publish "dist/npm/openclaw-clawscan-plugin-${{ steps.publish_state.outputs.package_version }}.tgz" --access public --provenance + + - name: Verify npm release metadata run: | - node --test npm/clawscan/test/*.test.mjs - node --test scripts/build-npm-package.test.mjs - node scripts/build-npm-package.mjs --version "${{ needs.build.outputs.version }}" --pack --smoke - - - name: Stage npm package - run: node scripts/build-npm-package.mjs --version "${{ needs.build.outputs.version }}" - - - name: Publish with npm trusted publishing - run: npm publish dist/npm/package --access public --provenance + set -euo pipefail + for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do + npm view "${package_name}@${{ steps.publish_state.outputs.package_version }}" dist.tarball dist.integrity --json + done update-homebrew-tap: name: Update Homebrew tap From 555782e8737596d3f0d144781dd7d9d37e8ee076 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:50:13 +1000 Subject: [PATCH 09/28] fix(plugin): harden runtime delivery --- .github/workflows/npm-release.yml | 87 ++++++++----------- .github/workflows/release.yml | 13 ++- npm/clawscan-plugin/src/gate-handler.ts | 16 +++- npm/clawscan-plugin/test/gate-handler.test.ts | 63 +++++++++++--- npm/clawscan-plugin/test/registration.test.ts | 4 +- npm/clawscan/test/resolve-binary.test.mjs | 12 +-- scripts/build-npm-package.mjs | 41 ++++++++- scripts/build-npm-package.test.mjs | 27 ++++++ 8 files changed, 190 insertions(+), 73 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index f17aca4..8b7e31a 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -161,6 +161,11 @@ jobs: process.stdout.write(normalizePackageVersion(process.env.RELEASE_TAG)); EOF )" + EXPECTED_DIST_TAG="$(node --input-type=module <<'EOF' + import { npmDistTagForVersion } from "./scripts/build-npm-package.mjs"; + process.stdout.write(npmDistTagForVersion(process.env.RELEASE_TAG)); + EOF + )" TAG_FILE="dist/npm/release-tag.txt" SHA_FILE="dist/npm/release-sha.txt" VERSION_FILE="dist/npm/package-version.txt" @@ -185,37 +190,34 @@ jobs: exit 1 fi echo "PACKAGE_VERSION=$EXPECTED_PACKAGE_VERSION" >> "$GITHUB_ENV" + echo "NPM_DIST_TAG=$EXPECTED_DIST_TAG" >> "$GITHUB_ENV" - - name: Resolve publish tarballs - id: publish_tarballs + - name: Resolve ClawScan publish tarball + id: publish_tarball run: | set -euo pipefail CLAWSCAN_TARBALL="dist/npm/openclaw-clawscan-${PACKAGE_VERSION}.tgz" - PLUGIN_TARBALL="dist/npm/openclaw-clawscan-plugin-${PACKAGE_VERSION}.tgz" - if [[ ! -f "$CLAWSCAN_TARBALL" || ! -f "$PLUGIN_TARBALL" ]]; then - echo "Prepared preflight tarballs were not both present." >&2 + if [[ ! -f "$CLAWSCAN_TARBALL" ]]; then + echo "Prepared ClawScan preflight tarball was not present." >&2 ls -la dist/npm >&2 || true exit 1 fi - echo "clawscan_path=$CLAWSCAN_TARBALL" >> "$GITHUB_OUTPUT" - echo "plugin_path=$PLUGIN_TARBALL" >> "$GITHUB_OUTPUT" + echo "path=$CLAWSCAN_TARBALL" >> "$GITHUB_OUTPUT" - - name: Inspect npm publish state + - name: Inspect ClawScan npm publish state id: publish_state run: | set -euo pipefail - inspect_package() { - local package_name="$1" - local tarball_path="$2" - local output_name="$3" - local published_version="" - if published_version="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then + package_name="@openclaw/clawscan" + tarball_path="${{ steps.publish_tarball.outputs.path }}" + published_version="" + if published_version="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 exit 1 fi - local remote_integrity="" - local local_integrity="" + remote_integrity="" + local_integrity="" remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity)" # shellcheck disable=SC2016 local_integrity="$(node --input-type=module -e ' @@ -228,46 +230,29 @@ jobs: echo "${package_name}@${PACKAGE_VERSION} does not match the prepared release tarball." >&2 exit 1 fi - if [[ "$package_name" == "@openclaw/clawscan-plugin" ]]; then - local binary_dependency="" - binary_dependency="$(npm view "${package_name}@${PACKAGE_VERSION}" 'dependencies.@openclaw/clawscan')" - if [[ "$binary_dependency" != "$PACKAGE_VERSION" ]]; then - echo "${package_name}@${PACKAGE_VERSION} does not depend on the matching ClawScan version." >&2 - exit 1 - fi - fi echo "${package_name}@${PACKAGE_VERSION} is already published with valid release metadata; skipping publish." - echo "${output_name}=false" >> "$GITHUB_OUTPUT" - return - fi - echo "${package_name}@${PACKAGE_VERSION} is not published yet." - echo "${output_name}=true" >> "$GITHUB_OUTPUT" - } - inspect_package "@openclaw/clawscan" "${{ steps.publish_tarballs.outputs.clawscan_path }}" "clawscan_needed" - inspect_package "@openclaw/clawscan-plugin" "${{ steps.publish_tarballs.outputs.plugin_path }}" "plugin_needed" + echo "clawscan_needed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "${package_name}@${PACKAGE_VERSION} is not published yet." + echo "clawscan_needed=true" >> "$GITHUB_OUTPUT" - name: Publish ClawScan binary package if: steps.publish_state.outputs.clawscan_needed == 'true' - run: npm publish "${{ steps.publish_tarballs.outputs.clawscan_path }}" --access public --provenance - - - name: Publish ClawScan OpenClaw plugin - if: steps.publish_state.outputs.plugin_needed == 'true' - run: npm publish "${{ steps.publish_tarballs.outputs.plugin_path }}" --access public --provenance + run: npm publish "${{ steps.publish_tarball.outputs.path }}" --access public --provenance --tag "$NPM_DIST_TAG" - - name: Verify npm release metadata + - name: Verify ClawScan npm release metadata run: | set -euo pipefail - for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do - NPM_DIST_JSON="" - for attempt in {1..12}; do - if NPM_DIST_JSON="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then - break - fi - if [[ "$attempt" == "12" ]]; then - cat /tmp/npm-view-error >&2 || true - exit 1 - fi - sleep 5 - done - printf '%s\n' "$NPM_DIST_JSON" + NPM_DIST_JSON="" + for attempt in {1..12}; do + if NPM_DIST_JSON="$(npm view "@openclaw/clawscan@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then + break + fi + if [[ "$attempt" == "12" ]]; then + cat /tmp/npm-view-error >&2 || true + exit 1 + fi + sleep 5 done + printf '%s\n' "$NPM_DIST_JSON" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 900b0d7..fe5400b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,7 +255,18 @@ jobs: run: | set -euo pipefail for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do - npm view "${package_name}@${{ steps.publish_state.outputs.package_version }}" dist.tarball dist.integrity --json + npm_dist_json="" + for attempt in {1..12}; do + if npm_dist_json="$(npm view "${package_name}@${{ steps.publish_state.outputs.package_version }}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$npm_dist_json" ]]; then + break + fi + if [[ "$attempt" == "12" ]]; then + cat /tmp/npm-view-error >&2 || true + exit 1 + fi + sleep 5 + done + printf '%s\n' "$npm_dist_json" done update-homebrew-tap: diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index dcb19a0..edfbdb0 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -1,4 +1,5 @@ import process from "node:process"; +import { join } from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; @@ -24,8 +25,18 @@ export type GateHandlerDependencies = { export type BeforeInstallEvent = { sourcePath: string; + sourcePathKind: "file" | "directory"; + targetType: "skill" | "plugin"; }; +export function scanTargetForEvent(event: BeforeInstallEvent): string { + if (event.sourcePathKind === "file") { + return event.sourcePath; + } + const manifestName = event.targetType === "plugin" ? "openclaw.plugin.json" : "SKILL.md"; + return join(event.sourcePath, manifestName); +} + function commandSucceeded(result: CommandResult): boolean { return ( result.code === 0 && @@ -109,6 +120,7 @@ const scanCommandOptions: CommandOptions = { export function createBeforeInstallHandler(dependencies: GateHandlerDependencies) { return async (event: BeforeInstallEvent): Promise => { try { + const scanTarget = scanTargetForEvent(event); let dockerAvailable = false; if ((dependencies.platform ?? process.platform) !== "win32") { try { @@ -127,7 +139,7 @@ export function createBeforeInstallHandler(dependencies: GateHandlerDependencies const scan = await dependencies.runCommand( [ binaryPath, - event.sourcePath, + scanTarget, "--config", fallbackConfigPath, "--profile", @@ -158,7 +170,7 @@ export function createBeforeInstallHandler(dependencies: GateHandlerDependencies const scan = await dependencies.runCommand( [ binaryPath, - event.sourcePath, + scanTarget, "--config", configPath, "--profile", diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index 4d1c7b9..f119860 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createBeforeInstallHandler, + scanTargetForEvent, + type BeforeInstallEvent, type CommandOptions, type CommandResult, } from "../src/gate-handler.ts"; @@ -33,6 +35,15 @@ function commandResult(overrides: Partial = {}): CommandResult { }; } +function beforeInstallEvent(overrides: Partial = {}): BeforeInstallEvent { + return { + sourcePath: "/candidate/demo-skill", + sourcePathKind: "directory", + targetType: "skill", + ...overrides, + }; +} + describe("createBeforeInstallHandler", () => { it("runs the full shipped profile and continues silently for a pass artifact", async () => { const calls: CommandCall[] = []; @@ -48,7 +59,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result, undefined); assert.deepEqual(calls, [ @@ -59,7 +70,7 @@ describe("createBeforeInstallHandler", () => { { argv: [ "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", - "/candidate/demo-skill", + "/candidate/demo-skill/SKILL.md", "--config", "/plugin/profiles/clawhub.yml", "--profile", @@ -104,7 +115,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.deepEqual(result, { findings: [ @@ -120,7 +131,7 @@ describe("createBeforeInstallHandler", () => { assert.deepEqual(calls[1], { argv: [ "/plugin/bin/clawscan", - "/candidate/demo-skill", + "/candidate/demo-skill/SKILL.md", "--config", "/plugin/profiles/clawhub.yml", "--profile", @@ -168,7 +179,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result, undefined); assert.ok(calls[1]?.argv.includes("clawhub-static")); @@ -196,7 +207,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(invocation, 2); assert.equal(result?.block, undefined); @@ -223,7 +234,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "C:\\candidate\\demo-skill" }); + const result = await handler(beforeInstallEvent({ sourcePath: "C:\\candidate\\demo-skill" })); assert.equal(calls.length, 1); assert.equal(calls[0]?.argv[0], "C:\\plugin\\clawscan.exe"); @@ -246,7 +257,7 @@ describe("createBeforeInstallHandler", () => { runCommand: async () => outputs.shift() ?? commandResult(), }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result?.block, true); assert.match( @@ -274,7 +285,7 @@ describe("createBeforeInstallHandler", () => { }, }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result?.block, true); assert.equal( @@ -305,7 +316,7 @@ describe("createBeforeInstallHandler", () => { runCommand: async () => outputs.shift() ?? commandResult(), }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result?.block, true); assert.equal(result?.blockReason, fixture.reason); @@ -324,7 +335,7 @@ describe("createBeforeInstallHandler", () => { runCommand: async () => outputs.shift() ?? commandResult(), }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal(result?.block, true); assert.equal( @@ -354,7 +365,7 @@ describe("createBeforeInstallHandler", () => { runCommand: async () => outputs.shift() ?? commandResult(), }); - const result = await handler({ sourcePath: "/candidate/demo-skill" }); + const result = await handler(beforeInstallEvent()); assert.equal( result?.blockReason, @@ -362,3 +373,31 @@ describe("createBeforeInstallHandler", () => { ); }); }); + +describe("scanTargetForEvent", () => { + it("disambiguates dual-layout candidate directories with the host target type", () => { + assert.equal(scanTargetForEvent(beforeInstallEvent()), "/candidate/demo-skill/SKILL.md"); + assert.equal( + scanTargetForEvent( + beforeInstallEvent({ + sourcePath: "/candidate/demo-plugin", + targetType: "plugin", + }), + ), + "/candidate/demo-plugin/openclaw.plugin.json", + ); + }); + + it("preserves file candidates selected by the host", () => { + assert.equal( + scanTargetForEvent( + beforeInstallEvent({ + sourcePath: "/candidate/plugin.tgz", + sourcePathKind: "file", + targetType: "plugin", + }), + ), + "/candidate/plugin.tgz", + ); + }); +}); diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts index 89c9a91..afd04dc 100644 --- a/npm/clawscan-plugin/test/registration.test.ts +++ b/npm/clawscan-plugin/test/registration.test.ts @@ -62,13 +62,15 @@ describe("registerInstallGate", () => { assert.ok(registeredHandler); const result = await registeredHandler({ sourcePath: "/untrusted/candidate", + sourcePathKind: "directory", + targetType: "skill", } satisfies BeforeInstallEvent); assert.equal(result, undefined); assert.equal(resolvedPath, "/trusted/custom.yml"); assert.deepEqual(commandCalls[1], [ "/plugin/bin/clawscan", - "/untrusted/candidate", + "/untrusted/candidate/SKILL.md", "--config", "/trusted/custom.yml", "--profile", diff --git a/npm/clawscan/test/resolve-binary.test.mjs b/npm/clawscan/test/resolve-binary.test.mjs index d012eb1..d8b3e4a 100644 --- a/npm/clawscan/test/resolve-binary.test.mjs +++ b/npm/clawscan/test/resolve-binary.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { join } from "node:path"; import { binaryFileName, platformKey, @@ -34,22 +35,23 @@ describe("binaryFileName", () => { describe("resolveBinaryPath", () => { it("resolves the bundled binary path relative to the package root", () => { - assert.match( + assert.equal( resolveBinaryPath({ packageRoot: "/tmp/package", platform: "darwin", arch: "arm64", }), - /\/tmp\/package\/binaries\/darwin-arm64\/clawscan$/, + join("/tmp/package", "binaries", "darwin-arm64", "clawscan"), ); }); }); describe("resolveBundledBinaryPath", () => { it("resolves the binary from the installed @openclaw/clawscan package", () => { - assert.match( - resolveBundledBinaryPath({ platform: "linux", arch: "x64" }), - /\/npm\/clawscan\/binaries\/linux-x64\/clawscan$/, + const resolved = resolveBundledBinaryPath({ platform: "linux", arch: "x64" }); + assert.equal( + resolved.endsWith(join("npm", "clawscan", "binaries", "linux-x64", "clawscan")), + true, ); }); }); diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index e89b823..cc32db9 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -42,6 +43,10 @@ export function binaryVersionFor(version) { return trimmed.startsWith("v") ? trimmed : `v${packageVersion}`; } +export function npmDistTagForVersion(version) { + return normalizePackageVersion(version).includes("-") ? "next" : "latest"; +} + export function platformKeyForTarget(target) { const arch = target.goarch === "amd64" ? "x64" : target.goarch; const platform = target.goos === "windows" ? "win32" : target.goos; @@ -56,13 +61,41 @@ export function preparePluginPackageJson(packageJson, packageVersion) { return { ...packageJson, version: packageVersion, + files: [...new Set([...(packageJson.files ?? []), "dist/"])], dependencies: { ...packageJson.dependencies, "@openclaw/clawscan": packageVersion, }, + openclaw: { + ...packageJson.openclaw, + runtimeExtensions: ["./dist/index.js"], + }, }; } +const pluginRuntimeSources = [ + "index.ts", + join("src", "artifact.ts"), + join("src", "gate-handler.ts"), + join("src", "register.ts"), +]; + +export function compilePluginTypeScript(source) { + return stripTypeScriptTypes(source, { mode: "transform" }).replace( + /((?:from\s+|import\s*)["'](?:\.\.?\/)[^"']+)\.ts(["'])/gu, + "$1.js$2", + ); +} + +async function stagePluginRuntime(pluginPackageSource, pluginPackageOut) { + for (const relativeSource of pluginRuntimeSources) { + const destination = join(pluginPackageOut, "dist", relativeSource.replace(/\.ts$/u, ".js")); + await mkdir(dirname(destination), { recursive: true }); + const source = await readFile(join(pluginPackageSource, relativeSource), "utf8"); + await writeFile(destination, compilePluginTypeScript(source)); + } +} + function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? repoRoot, @@ -142,6 +175,7 @@ async function stagePackages(options) { }); await rm(join(pluginPackageOut, "test"), { recursive: true, force: true }); await cp(join(repoRoot, "LICENSE"), join(pluginPackageOut, "LICENSE")); + await stagePluginRuntime(pluginPackageSource, pluginPackageOut); const packageJsonPath = join(packageOut, "package.json"); const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); @@ -240,6 +274,7 @@ async function smokePackages( } await readFile(join(installedPluginRoot, "openclaw.plugin.json"), "utf8"); await readFile(join(installedPluginRoot, "profiles", "clawhub.yml"), "utf8"); + await readFile(join(installedPluginRoot, "dist", "index.js"), "utf8"); const hostPackageRoot = join(pluginPrefix, "node_modules", "openclaw"); await mkdir(join(hostPackageRoot, "plugin-sdk"), { recursive: true }); @@ -264,7 +299,11 @@ async function smokePackages( ); const entrypointSmokeRoot = join(pluginPrefix, "packed-entrypoint-smoke"); await cp(installedPluginRoot, entrypointSmokeRoot, { recursive: true }); - const entrypointUrl = pathToFileURL(join(entrypointSmokeRoot, "index.ts")).href; + const runtimeEntry = installedPackageJson.openclaw?.runtimeExtensions?.[0]; + if (runtimeEntry !== "./dist/index.js") { + throw new Error("Installed ClawScan plugin did not declare its built runtime entrypoint."); + } + const entrypointUrl = pathToFileURL(join(entrypointSmokeRoot, runtimeEntry)).href; run( "node", [ diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index e390215..1fcfa94 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { binaryNameForTarget, + compilePluginTypeScript, normalizePackageVersion, normalizeBuildDate, + npmDistTagForVersion, packageTargets, platformKeyForTarget, preparePluginPackageJson, @@ -27,6 +29,13 @@ describe("normalizePackageVersion", () => { }); }); +describe("npmDistTagForVersion", () => { + it("keeps stable releases on latest and prereleases on next", () => { + assert.equal(npmDistTagForVersion("v1.2.3"), "latest"); + assert.equal(npmDistTagForVersion("1.2.3-beta.1"), "next"); + }); +}); + describe("normalizeBuildDate", () => { it("derives a stable UTC build date from commit metadata", () => { assert.equal(normalizeBuildDate("2026-07-28T12:34:56+10:00"), "2026-07-28T02:34:56Z"); @@ -71,8 +80,26 @@ describe("preparePluginPackageJson", () => { { name: "@openclaw/clawscan-plugin", version: "1.2.3", + files: ["dist/"], dependencies: { "@openclaw/clawscan": "1.2.3" }, + openclaw: { + runtimeExtensions: ["./dist/index.js"], + }, }, ); }); }); + +describe("compilePluginTypeScript", () => { + it("removes types and rewrites local TypeScript imports for the installed runtime", () => { + const compiled = compilePluginTypeScript( + 'import type { Host } from "openclaw/plugin-sdk/plugin-entry";\n' + + 'import { register } from "./src/register.ts";\n' + + "const api: Host = register;\n", + ); + + assert.doesNotMatch(compiled, /import type/); + assert.match(compiled, /from "\.\/src\/register\.js"/); + assert.doesNotMatch(compiled, /: Host/); + }); +}); From 4cb7f7a9c870f63488b46627727680e47b7007b2 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:15:29 +1000 Subject: [PATCH 10/28] fix(plugin): scan complete install candidates --- .github/workflows/npm-release.yml | 5 +++-- npm/clawscan-plugin/src/gate-handler.ts | 22 ++++++++++++++++--- npm/clawscan-plugin/test/gate-handler.test.ts | 12 ++++++---- npm/clawscan-plugin/test/registration.test.ts | 2 +- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 8b7e31a..3e8b47b 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -1,4 +1,5 @@ -name: NPM Release +# Plugin trusted publishing intentionally lives only in release.yml. +name: ClawScan Binary NPM Promotion on: workflow_dispatch: @@ -139,7 +140,7 @@ jobs: set -euo pipefail RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)" # shellcheck disable=SC2016 - printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });' + printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "ClawScan Binary NPM Promotion"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });' - name: Download prepared npm tarball uses: actions/download-artifact@v4 diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index edfbdb0..6fef955 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -1,4 +1,5 @@ import process from "node:process"; +import { lstatSync } from "node:fs"; import { join } from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; @@ -29,12 +30,27 @@ export type BeforeInstallEvent = { targetType: "skill" | "plugin"; }; -export function scanTargetForEvent(event: BeforeInstallEvent): string { +function isRegularFile(path: string): boolean { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + +export function scanTargetForEvent( + event: BeforeInstallEvent, + pluginManifestExists: (path: string) => boolean = isRegularFile, +): string { if (event.sourcePathKind === "file") { return event.sourcePath; } - const manifestName = event.targetType === "plugin" ? "openclaw.plugin.json" : "SKILL.md"; - return join(event.sourcePath, manifestName); + if (event.targetType === "plugin") { + return join(event.sourcePath, "openclaw.plugin.json"); + } + return pluginManifestExists(join(event.sourcePath, "openclaw.plugin.json")) + ? join(event.sourcePath, "SKILL.md") + : event.sourcePath; } function commandSucceeded(result: CommandResult): boolean { diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index f119860..0b6f753 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -70,7 +70,7 @@ describe("createBeforeInstallHandler", () => { { argv: [ "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", - "/candidate/demo-skill/SKILL.md", + "/candidate/demo-skill", "--config", "/plugin/profiles/clawhub.yml", "--profile", @@ -131,7 +131,7 @@ describe("createBeforeInstallHandler", () => { assert.deepEqual(calls[1], { argv: [ "/plugin/bin/clawscan", - "/candidate/demo-skill/SKILL.md", + "/candidate/demo-skill", "--config", "/plugin/profiles/clawhub.yml", "--profile", @@ -375,8 +375,12 @@ describe("createBeforeInstallHandler", () => { }); describe("scanTargetForEvent", () => { - it("disambiguates dual-layout candidate directories with the host target type", () => { - assert.equal(scanTargetForEvent(beforeInstallEvent()), "/candidate/demo-skill/SKILL.md"); + it("scans full skill directories and disambiguates dual-layout candidates", () => { + assert.equal(scanTargetForEvent(beforeInstallEvent()), "/candidate/demo-skill"); + assert.equal( + scanTargetForEvent(beforeInstallEvent(), () => true), + "/candidate/demo-skill/SKILL.md", + ); assert.equal( scanTargetForEvent( beforeInstallEvent({ diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts index afd04dc..bd03ddd 100644 --- a/npm/clawscan-plugin/test/registration.test.ts +++ b/npm/clawscan-plugin/test/registration.test.ts @@ -70,7 +70,7 @@ describe("registerInstallGate", () => { assert.equal(resolvedPath, "/trusted/custom.yml"); assert.deepEqual(commandCalls[1], [ "/plugin/bin/clawscan", - "/untrusted/candidate/SKILL.md", + "/untrusted/candidate", "--config", "/trusted/custom.yml", "--profile", From 735c24406f9e3953ff5f8af7144b58309960c1be Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:16:01 +1000 Subject: [PATCH 11/28] fix(plugin): align with declarative gate policy --- npm/clawscan-plugin/profiles/clawhub.yml | 46 ++++++++++++- npm/clawscan-plugin/src/artifact.ts | 48 +++++++++++++- npm/clawscan-plugin/test/artifact.test.ts | 65 +++++++++++++++---- npm/clawscan-plugin/test/gate-handler.test.ts | 18 +++-- npm/clawscan-plugin/test/package.test.mjs | 7 +- 5 files changed, 158 insertions(+), 26 deletions(-) diff --git a/npm/clawscan-plugin/profiles/clawhub.yml b/npm/clawscan-plugin/profiles/clawhub.yml index 4aacb1b..45ff933 100644 --- a/npm/clawscan-plugin/profiles/clawhub.yml +++ b/npm/clawscan-plugin/profiles/clawhub.yml @@ -5,12 +5,52 @@ profiles: scanners: - id: skillspector gate: - native: true + rules: + - id: execution-failed + path: + - execution_successful + - executionSuccessful + equals: false + action: block + - id: do-not-install + path: + - risk_recommendation|riskRecommendation|recommendation + - risk_assessment.recommendation|risk_recommendation|riskRecommendation + - riskAssessment.recommendation|risk_recommendation|riskRecommendation + equals: DO_NOT_INSTALL + normalize: identifier + action: block + - id: critical-finding + path: + - filtered_findings[].severity|risk_severity|level + - filteredFindings[].severity|risk_severity|level + - findings[].severity|risk_severity|level + - issues[].severity|risk_severity|level + - vulnerabilities[].severity|risk_severity|level + equals: CRITICAL + normalize: identifier + fallback: root + action: block + - id: high-finding + path: + - filtered_findings[].severity|risk_severity|level + - filteredFindings[].severity|risk_severity|level + - findings[].severity|risk_severity|level + - issues[].severity|risk_severity|level + - vulnerabilities[].severity|risk_severity|level + equals: HIGH + normalize: identifier + fallback: root + action: warn - id: clawscan-static gate: - native: true + rules: &static-gate-rules + - id: any-finding + path: findings[] + exists: true + action: warn clawhub-static: scanners: - id: clawscan-static gate: - native: true + rules: *static-gate-rules diff --git a/npm/clawscan-plugin/src/artifact.ts b/npm/clawscan-plugin/src/artifact.ts index 44c02ec..77a1fef 100644 --- a/npm/clawscan-plugin/src/artifact.ts +++ b/npm/clawscan-plugin/src/artifact.ts @@ -18,6 +18,47 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function scannerCompleted(value: unknown): value is Record { + return isRecord(value) && value.status === "completed" && cleanText(value.error, 1) === ""; +} + +function skillSpectorEvidenceUsable(raw: unknown): boolean { + if (!isRecord(raw) || raw.execution_successful === false || cleanText(raw.error, 1) !== "") { + return false; + } + const status = cleanText(raw.status, 40).toLowerCase(); + if (["benign", "safe", "clean", "suspicious", "malicious"].includes(status)) { + return true; + } + const assessment = isRecord(raw.risk_assessment) + ? raw.risk_assessment + : isRecord(raw.riskAssessment) + ? raw.riskAssessment + : {}; + if ( + cleanText(raw.recommendation, 1) !== "" || + cleanText(assessment.recommendation, 1) !== "" || + typeof raw.score === "number" || + typeof assessment.score === "number" + ) { + return true; + } + return ["filtered_findings", "filteredFindings", "findings", "issues", "vulnerabilities"].some( + (key) => Array.isArray(raw[key]), + ); +} + +function scannerEvidenceUsable(scanner: string, result: Record): boolean { + if (scanner === "clawscan-static") { + return ( + isRecord(result.raw) && + result.raw.schemaVersion === "clawscan-static-v1" && + Array.isArray(result.raw.findings) + ); + } + return scanner !== "skillspector" || skillSpectorEvidenceUsable(result.raw); +} + function cleanText(value: unknown, limit: number): string { if (typeof value !== "string") { return ""; @@ -126,12 +167,15 @@ export function gateResultFromArtifact( } for (const scanner of requiredScanners) { const result = parsed.scanners[scanner]; - if (!isRecord(result) || result.status !== "completed") { + if (!scannerCompleted(result)) { return blockForInvalidArtifact(`required scanner ${scanner} did not complete`); } + if (!scannerEvidenceUsable(scanner, result)) { + return blockForInvalidArtifact(`required scanner ${scanner} returned unusable evidence`); + } } for (const [scanner, result] of Object.entries(parsed.scanners)) { - if (!isRecord(result) || result.status !== "completed") { + if (!scannerCompleted(result)) { const scannerName = cleanRuleSegment(scanner, "unknown-scanner"); return blockForInvalidArtifact(`scanner ${scannerName} did not complete`); } diff --git a/npm/clawscan-plugin/test/artifact.test.ts b/npm/clawscan-plugin/test/artifact.test.ts index 056bed0..afb948d 100644 --- a/npm/clawscan-plugin/test/artifact.test.ts +++ b/npm/clawscan-plugin/test/artifact.test.ts @@ -2,6 +2,17 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { gateResultFromArtifact } from "../src/artifact.ts"; +const skillSpectorCompleted = { + status: "completed", + error: "", + raw: { status: "clean", findings: [] }, +}; +const staticCompleted = { + status: "completed", + error: "", + raw: { schemaVersion: "clawscan-static-v1", findings: [] }, +}; + describe("gateResultFromArtifact", () => { it("continues silently for a valid pass artifact", () => { const result = gateResultFromArtifact( @@ -10,8 +21,8 @@ describe("gateResultFromArtifact", () => { gate: "pass", gateRules: [], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: skillSpectorCompleted, + "clawscan-static": staticCompleted, }, }), ["skillspector", "clawscan-static"], @@ -44,8 +55,8 @@ describe("gateResultFromArtifact", () => { }, ], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: skillSpectorCompleted, + "clawscan-static": staticCompleted, }, }), ["skillspector", "clawscan-static"], @@ -87,8 +98,8 @@ describe("gateResultFromArtifact", () => { }, ], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: skillSpectorCompleted, + "clawscan-static": staticCompleted, }, }), ["skillspector", "clawscan-static"], @@ -153,7 +164,7 @@ describe("gateResultFromArtifact", () => { schemaVersion: "clawscan-run-v1", gate: "maybe", gateRules: [], - scanners: { skillspector: { status: "completed" } }, + scanners: { skillspector: skillSpectorCompleted }, }), requiredScanners: ["skillspector"], }, @@ -167,7 +178,7 @@ describe("gateResultFromArtifact", () => { }), requiredScanners: ["skillspector"], }, - ...["skipped", "failed"].map((status) => ({ + ...["skipped", "completed"].map((status) => ({ name: `a ${status} required scanner`, stdout: JSON.stringify({ schemaVersion: "clawscan-run-v1", @@ -195,8 +206,8 @@ describe("gateResultFromArtifact", () => { gate: "pass", gateRules: [], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: skillSpectorCompleted, + "clawscan-static": staticCompleted, "team-scanner": { status: "failed" }, }, }), @@ -210,6 +221,34 @@ describe("gateResultFromArtifact", () => { ); }); + for (const [name, scanner, raw] of [ + ["completion-only SkillSpector", "skillspector", { status: "completed" }], + [ + "failed SkillSpector execution", + "skillspector", + { status: "clean", execution_successful: false }, + ], + ["invalid static scanner", "clawscan-static", { schemaVersion: "wrong", findings: [] }], + ] as const) { + it(`fails closed for ${name} evidence`, () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "pass", + gateRules: [], + scanners: { [scanner]: { status: "completed", error: "", raw } }, + }), + [scanner], + ); + + assert.equal(result?.block, true); + assert.equal( + result?.blockReason, + `ClawScan blocked installation: required scanner ${scanner} returned unusable evidence`, + ); + }); + } + it("fails closed when an artifact contains no scanner results", () => { const result = gateResultFromArtifact( JSON.stringify({ @@ -240,8 +279,8 @@ describe("gateResultFromArtifact", () => { }, ], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: skillSpectorCompleted, + "clawscan-static": staticCompleted, }, }), ["skillspector", "clawscan-static"], @@ -266,7 +305,7 @@ describe("gateResultFromArtifact", () => { action: "warn", })), scanners: { - "clawscan-static": { status: "completed" }, + "clawscan-static": staticCompleted, }, }), ["clawscan-static"], diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index 0b6f753..fc92b50 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -13,13 +13,19 @@ type CommandCall = { options: CommandOptions; }; +const staticCompleted = { + status: "completed", + error: "", + raw: { schemaVersion: "clawscan-static-v1", findings: [] }, +}; + const passArtifact = JSON.stringify({ schemaVersion: "clawscan-run-v1", gate: "pass", gateRules: [], scanners: { - skillspector: { status: "completed" }, - "clawscan-static": { status: "completed" }, + skillspector: { status: "completed", error: "", raw: { status: "clean", findings: [] } }, + "clawscan-static": staticCompleted, }, }); @@ -98,7 +104,7 @@ describe("createBeforeInstallHandler", () => { gate: "pass", gateRules: [], scanners: { - "clawscan-static": { status: "completed" }, + "clawscan-static": staticCompleted, }, }); const outputs = [ @@ -163,7 +169,7 @@ describe("createBeforeInstallHandler", () => { gate: "pass", gateRules: [], scanners: { - "clawscan-static": { status: "completed" }, + "clawscan-static": staticCompleted, }, }), }), @@ -201,7 +207,7 @@ describe("createBeforeInstallHandler", () => { schemaVersion: "clawscan-run-v1", gate: "pass", gateRules: [], - scanners: { "clawscan-static": { status: "completed" } }, + scanners: { "clawscan-static": staticCompleted }, }), }); }, @@ -228,7 +234,7 @@ describe("createBeforeInstallHandler", () => { schemaVersion: "clawscan-run-v1", gate: "pass", gateRules: [], - scanners: { "clawscan-static": { status: "completed" } }, + scanners: { "clawscan-static": staticCompleted }, }), }); }, diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs index 0d5c818..a29b314 100644 --- a/npm/clawscan-plugin/test/package.test.mjs +++ b/npm/clawscan-plugin/test/package.test.mjs @@ -63,12 +63,15 @@ describe("@openclaw/clawscan-plugin package", () => { assert.doesNotMatch(handler, new RegExp(forbiddenModule)); }); - it("ships a no-judge profile with both required native gate scanners", async () => { + it("ships a no-judge profile with both required declarative gate scanners", async () => { const profile = await readFile(join(packageRoot, "profiles", "clawhub.yml"), "utf8"); assert.match(profile, /id: skillspector/); assert.match(profile, /id: clawscan-static/); - assert.match(profile, /native: true/); + assert.match(profile, /id: execution-failed/); + assert.match(profile, /id: critical-finding/); + assert.match(profile, /id: any-finding/); + assert.doesNotMatch(profile, /native:/); assert.doesNotMatch(profile, /\bjudge:/); }); }); From b7f8b1e98a4412b5e8dd0eafa73e6b48f2a2f7a3 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:27:51 +1000 Subject: [PATCH 12/28] fix(plugin): normalize bundled config identity --- npm/clawscan-plugin/src/register.ts | 20 ++++++++++++---- npm/clawscan-plugin/test/registration.test.ts | 24 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/npm/clawscan-plugin/src/register.ts b/npm/clawscan-plugin/src/register.ts index 3143e07..ad4accf 100644 --- a/npm/clawscan-plugin/src/register.ts +++ b/npm/clawscan-plugin/src/register.ts @@ -38,8 +38,12 @@ function configuredString( return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function requiredScannersForShippedConfig(configPath: string, profile: string): readonly string[] { - if (configPath !== DEFAULT_CONFIG_PATH) { +export function requiredScannersForShippedConfig( + configPath: string, + defaultConfigPath: string, + profile: string, +): readonly string[] { + if (configPath !== defaultConfigPath) { return []; } if (profile === "clawhub") { @@ -54,12 +58,18 @@ function requiredScannersForShippedConfig(configPath: string, profile: string): export function registerInstallGate(api: GatePluginApi, resolveBinaryPath: () => string): void { const configPath = configuredString(api.pluginConfig, "configPath", DEFAULT_CONFIG_PATH); const profile = configuredString(api.pluginConfig, "profile", DEFAULT_PROFILE); + const resolvedConfigPath = api.resolvePath(configPath); + const resolvedDefaultConfigPath = api.resolvePath(DEFAULT_CONFIG_PATH); const handler = createBeforeInstallHandler({ resolveBinaryPath, - resolveConfigPath: () => api.resolvePath(configPath), - resolveFallbackConfigPath: () => api.resolvePath(DEFAULT_CONFIG_PATH), + resolveConfigPath: () => resolvedConfigPath, + resolveFallbackConfigPath: () => resolvedDefaultConfigPath, profile, - requiredScanners: requiredScannersForShippedConfig(configPath, profile), + requiredScanners: requiredScannersForShippedConfig( + resolvedConfigPath, + resolvedDefaultConfigPath, + profile, + ), runCommand: async (argv, options) => await api.runtime.system.runCommandWithTimeout(argv, options), }); diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts index bd03ddd..26afb25 100644 --- a/npm/clawscan-plugin/test/registration.test.ts +++ b/npm/clawscan-plugin/test/registration.test.ts @@ -1,12 +1,26 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { BeforeInstallEvent } from "../src/gate-handler.ts"; -import { registerInstallGate, type RegisteredHandler } from "../src/register.ts"; +import { + registerInstallGate, + requiredScannersForShippedConfig, + type RegisteredHandler, +} from "../src/register.ts"; describe("registerInstallGate", () => { + it("recognizes equivalent resolved paths to the shipped profile", () => { + assert.deepEqual( + requiredScannersForShippedConfig( + "/plugin/profiles/clawhub.yml", + "/plugin/profiles/clawhub.yml", + "clawhub", + ), + ["skillspector", "clawscan-static"], + ); + }); + it("registers a high-priority before_install hook with an explicit resolved config", async () => { let registeredHandler: RegisteredHandler | undefined; - let resolvedPath = ""; const commandCalls: string[][] = []; registerInstallGate( { @@ -14,10 +28,7 @@ describe("registerInstallGate", () => { configPath: "/trusted/custom.yml", profile: "clawhub", }, - resolvePath: (input) => { - resolvedPath = input; - return input; - }, + resolvePath: (input) => input, runtime: { system: { runCommandWithTimeout: async (argv) => { @@ -67,7 +78,6 @@ describe("registerInstallGate", () => { } satisfies BeforeInstallEvent); assert.equal(result, undefined); - assert.equal(resolvedPath, "/trusted/custom.yml"); assert.deepEqual(commandCalls[1], [ "/plugin/bin/clawscan", "/untrusted/candidate", From 1d51fb90af5c2f3e6e88a2ff11399d8a43be3dd4 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:47:03 +1000 Subject: [PATCH 13/28] fix(plugin): scan compatible bundle directories --- npm/clawscan-plugin/src/gate-handler.ts | 3 ++- npm/clawscan-plugin/test/gate-handler.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index 6fef955..238f808 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -46,7 +46,8 @@ export function scanTargetForEvent( return event.sourcePath; } if (event.targetType === "plugin") { - return join(event.sourcePath, "openclaw.plugin.json"); + const manifestPath = join(event.sourcePath, "openclaw.plugin.json"); + return pluginManifestExists(manifestPath) ? manifestPath : event.sourcePath; } return pluginManifestExists(join(event.sourcePath, "openclaw.plugin.json")) ? join(event.sourcePath, "SKILL.md") diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index fc92b50..c31296f 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -393,9 +393,20 @@ describe("scanTargetForEvent", () => { sourcePath: "/candidate/demo-plugin", targetType: "plugin", }), + () => true, ), "/candidate/demo-plugin/openclaw.plugin.json", ); + assert.equal( + scanTargetForEvent( + beforeInstallEvent({ + sourcePath: "/candidate/codex-bundle", + targetType: "plugin", + }), + () => false, + ), + "/candidate/codex-bundle", + ); }); it("preserves file candidates selected by the host", () => { From ecd8d74bb8b23a5922cd84a6ce7b95ce0be68c4e Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:10:59 +1000 Subject: [PATCH 14/28] feat(release): add Windows ARM64 binaries --- npm/clawscan/lib/resolve-binary.mjs | 1 + npm/clawscan/test/resolve-binary.test.mjs | 1 + scripts/build-npm-package.mjs | 1 + scripts/build-npm-package.test.mjs | 1 + scripts/build-release.sh | 1 + 5 files changed, 5 insertions(+) diff --git a/npm/clawscan/lib/resolve-binary.mjs b/npm/clawscan/lib/resolve-binary.mjs index b3777af..2a8b7ce 100644 --- a/npm/clawscan/lib/resolve-binary.mjs +++ b/npm/clawscan/lib/resolve-binary.mjs @@ -8,6 +8,7 @@ const supportedPlatforms = new Set([ "darwin-x64", "linux-arm64", "linux-x64", + "win32-arm64", "win32-x64", ]); diff --git a/npm/clawscan/test/resolve-binary.test.mjs b/npm/clawscan/test/resolve-binary.test.mjs index d8b3e4a..17e0965 100644 --- a/npm/clawscan/test/resolve-binary.test.mjs +++ b/npm/clawscan/test/resolve-binary.test.mjs @@ -15,6 +15,7 @@ describe("platformKey", () => { assert.equal(platformKey("darwin", "x64"), "darwin-x64"); assert.equal(platformKey("darwin", "arm64"), "darwin-arm64"); assert.equal(platformKey("win32", "x64"), "win32-x64"); + assert.equal(platformKey("win32", "arm64"), "win32-arm64"); }); it("rejects unsupported platform and arch pairs with a useful message", () => { diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index cc32db9..0d670ba 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -17,6 +17,7 @@ export const packageTargets = [ { goos: "linux", goarch: "amd64" }, { goos: "linux", goarch: "arm64" }, { goos: "windows", goarch: "amd64" }, + { goos: "windows", goarch: "arm64" }, ]; export function normalizePackageVersion(version) { diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index 1fcfa94..5ecd498 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -56,6 +56,7 @@ describe("package target mapping", () => { ["linux", "amd64", "linux-x64"], ["linux", "arm64", "linux-arm64"], ["windows", "amd64", "win32-x64"], + ["windows", "arm64", "win32-arm64"], ], ); }); diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 99b64ad..adfd978 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -13,6 +13,7 @@ platforms=( "linux/amd64" "linux/arm64" "windows/amd64" + "windows/arm64" ) rm -rf "$dist_dir" From 2954e46b3893289cd0e0a9ff0887f88586f6a29a Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:10:43 +1000 Subject: [PATCH 15/28] feat(profiles): publish config schema --- README.md | 5 + docs/profiles.md | 7 + go.mod | 3 + go.sum | 6 + internal/profiles/resolver.go | 386 ----------------------------- internal/profiles/yaml.go | 416 ++++++++++++++++++++++++++++++++ schemas/clawscan.schema.json | 363 ++++++++++++++++++++++++++++ schemas/clawscan.schema_test.go | 219 +++++++++++++++++ 8 files changed, 1019 insertions(+), 386 deletions(-) create mode 100644 internal/profiles/yaml.go create mode 100644 schemas/clawscan.schema.json create mode 100644 schemas/clawscan.schema_test.go diff --git a/README.md b/README.md index e440faf..6cf9362 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ Custom profiles can be created in `.clawscan.yml`. This is useful for version controlling iterations on your profile, creating multiple profiles to run over the same skills, etc ```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/openclaw/clawscan/main/schemas/clawscan.schema.json + version: 1 profiles: review: @@ -213,6 +215,9 @@ profiles: - < {{ prompt:./prompt.md }} ``` +The published schema provides editor completion and catches invalid profile +fields before a scan starts. ClawScan also validates every config at runtime. + ## Judge Harness `--judge` hands scanner evidence to an external agent command so it can inspect diff --git a/docs/profiles.md b/docs/profiles.md index 80d6b71..f34f008 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -64,6 +64,8 @@ Custom profiles can be created in `.clawscan.yml`. This is useful for version controlling iterations on your profile, creating multiple profiles to run over the same skills, etc ```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/openclaw/clawscan/main/schemas/clawscan.schema.json + version: 1 profiles: review: @@ -86,6 +88,11 @@ profiles: - < {{ prompt:./prompt.md }} ``` +The schema gives editors completion and catches misspelled fields, invalid +types, unsupported values, and malformed gate rules before a scan starts. +ClawScan still validates the file when it loads so correctness does not depend +on editor support. + Sandbox mounts must use existing absolute host paths. A string mount is read-only; set `write: true` only for a directory the scanner genuinely needs to modify. The CLI equivalents are repeatable `--sandbox-mount /path` and diff --git a/go.mod b/go.mod index 017686f..50684df 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.26.1 require ( github.com/alchemy/json5 v0.2.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 gopkg.in/yaml.v3 v3.0.1 ) + +require golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 3a70621..a1209df 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ github.com/alchemy/json5 v0.2.0 h1:M8hmUpCyGlzdiWaxY4RI/rDcLAlFTtjB6j49eUpk+FE= github.com/alchemy/json5 v0.2.0/go.mod h1:kVE7UoCjGVIlxOXFCzKn6T34nyC/OctNTNG81MLOqiw= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index dc5ff66..188b3db 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -24,8 +24,6 @@ var builtinProfileConfigPaths = []string{ "clawhub/clawscan.yml", } -var jsonIntegerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) - type Config struct { Version int `yaml:"version"` Sandbox *Sandbox `yaml:"sandbox,omitempty"` @@ -56,354 +54,6 @@ type ProfileScanner struct { mapping bool } -type ProfileScannerGate struct { - BlockOnExitCode *profileExitCodeRule `yaml:"blockOnExitCode,omitempty"` - WarnOnExitCode *profileExitCodeRule `yaml:"warnOnExitCode,omitempty"` - Rules []profileJSONGateRule `yaml:"rules,omitempty"` -} - -type profileJSONGateRule struct { - ID string `yaml:"id"` - Paths []string `yaml:"-"` - Equals *yaml.Node `yaml:"equals,omitempty"` - Exists bool `yaml:"exists,omitempty"` - Normalize string `yaml:"normalize,omitempty"` - Fallback string `yaml:"fallback,omitempty"` - Action string `yaml:"action"` - equalsSet bool - existsSet bool - equalsJSON json.RawMessage -} - -type profileExitCodeRule struct { - Codes []int - Nonzero bool -} - -func (rule *profileExitCodeRule) UnmarshalYAML(node *yaml.Node) error { - node = resolvedYAMLNode(node) - switch node.Kind { - case yaml.ScalarNode: - if node.Tag == "!!str" && node.Value == "nonzero" { - rule.Nonzero = true - return nil - } - if node.Tag == "!!int" { - var code int - if err := node.Decode(&code); err == nil && code >= 0 && code <= runner.MaxGateExitCode { - rule.Codes = []int{code} - return nil - } - return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) - } - case yaml.SequenceNode: - if len(node.Content) == 0 { - return errors.New("exit-code gate rule must not be an empty list") - } - codes := make([]int, 0, len(node.Content)) - for _, item := range node.Content { - item = resolvedYAMLNode(item) - if item.Kind != yaml.ScalarNode || item.Tag != "!!int" { - return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) - } - var code int - if err := item.Decode(&code); err != nil || code < 0 || code > runner.MaxGateExitCode { - return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) - } - codes = append(codes, code) - } - rule.Codes = codes - return nil - } - return fmt.Errorf(`exit-code gate rule must be an integer from 0 through %d, a list of those integers, or "nonzero"`, runner.MaxGateExitCode) -} - -func (rule profileExitCodeRule) MarshalYAML() (interface{}, error) { - if rule.Nonzero { - return "nonzero", nil - } - switch len(rule.Codes) { - case 0: - return nil, errors.New("exit-code gate rule must include at least one exit code") - case 1: - return rule.Codes[0], nil - default: - return append([]int(nil), rule.Codes...), nil - } -} - -func (rule *profileJSONGateRule) UnmarshalYAML(node *yaml.Node) error { - node = resolvedYAMLNode(node) - if node.Kind != yaml.MappingNode { - return errors.New("JSON gate rule must be an object") - } - seenFields := make(map[string]bool, len(node.Content)/2) - for index := 0; index < len(node.Content); index += 2 { - key := node.Content[index].Value - if seenFields[key] { - return fmt.Errorf("JSON gate rule %s has duplicate field %s", rule.ID, key) - } - seenFields[key] = true - value := resolvedYAMLNode(node.Content[index+1]) - switch key { - case "id": - if err := value.Decode(&rule.ID); err != nil { - return err - } - case "path": - switch value.Kind { - case yaml.ScalarNode: - if value.Tag != "!!str" { - return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) - } - rule.Paths = []string{value.Value} - case yaml.SequenceNode: - if len(value.Content) == 0 { - return fmt.Errorf("JSON gate rule %s path list must not be empty", rule.ID) - } - rule.Paths = make([]string, 0, len(value.Content)) - for _, pathNode := range value.Content { - pathNode = resolvedYAMLNode(pathNode) - if pathNode.Kind != yaml.ScalarNode || pathNode.Tag != "!!str" { - return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) - } - rule.Paths = append(rule.Paths, pathNode.Value) - } - default: - return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) - } - case "action": - if err := value.Decode(&rule.Action); err != nil { - return err - } - case "equals": - if value.Kind != yaml.ScalarNode || value.Tag == "!!null" { - return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) - } - switch value.Tag { - case "!!str": - rule.equalsJSON, _ = json.Marshal(value.Value) - case "!!bool": - var parsed bool - if err := value.Decode(&parsed); err != nil { - return fmt.Errorf("JSON gate rule %s equals must be a boolean", rule.ID) - } - rule.equalsJSON, _ = json.Marshal(parsed) - case "!!int": - if !validJSONGateNumber(value.Value) { - return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) - } - if !jsonIntegerPattern.MatchString(value.Value) { - return fmt.Errorf("JSON gate rule %s equals must be a JSON integer", rule.ID) - } - rule.equalsJSON = append(json.RawMessage(nil), value.Value...) - case "!!float": - if !validJSONGateNumber(value.Value) { - return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) - } - rule.equalsJSON = append(json.RawMessage(nil), value.Value...) - default: - return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) - } - rule.Equals = value - rule.equalsSet = true - case "exists": - if value.Kind != yaml.ScalarNode || value.Tag != "!!bool" { - return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) - } - rule.existsSet = true - if err := value.Decode(&rule.Exists); err != nil { - return err - } - case "normalize": - if err := value.Decode(&rule.Normalize); err != nil { - return err - } - case "fallback": - if err := value.Decode(&rule.Fallback); err != nil { - return err - } - default: - return fmt.Errorf("field %s not found in type profiles.profileJSONGateRule", key) - } - } - if strings.TrimSpace(rule.ID) == "" { - return errors.New("JSON gate rule id must not be empty") - } - if len(rule.Paths) == 0 { - return fmt.Errorf("JSON gate rule %s path must not be empty", rule.ID) - } - seenPaths := map[string]bool{} - for _, path := range rule.Paths { - if err := runner.ValidateJSONGatePath(path); err != nil { - return fmt.Errorf("JSON gate rule %s path %q is invalid: %w", rule.ID, path, err) - } - if seenPaths[path] { - return fmt.Errorf("JSON gate rule %s has duplicate path %q", rule.ID, path) - } - seenPaths[path] = true - } - if rule.Action != "warn" && rule.Action != "block" { - return fmt.Errorf("JSON gate rule %s action must be warn or block", rule.ID) - } - if rule.existsSet && !rule.Exists { - return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) - } - if rule.equalsSet == rule.existsSet { - return fmt.Errorf("JSON gate rule %s must include exactly one of equals or exists: true", rule.ID) - } - if rule.Normalize != "" && rule.Normalize != "identifier" { - return fmt.Errorf("JSON gate rule %s normalize must be identifier", rule.ID) - } - if rule.Normalize != "" && (!rule.equalsSet || rule.Equals.Tag != "!!str") { - return fmt.Errorf("JSON gate rule %s normalize requires a string equals value", rule.ID) - } - if rule.Fallback != "" && rule.Fallback != "root" { - return fmt.Errorf("JSON gate rule %s fallback must be root", rule.ID) - } - return nil -} - -func validJSONGateNumber(value string) bool { - if !json.Valid([]byte(value)) { - return false - } - decoder := json.NewDecoder(strings.NewReader(value)) - decoder.UseNumber() - var parsed any - if err := decoder.Decode(&parsed); err != nil { - return false - } - _, ok := parsed.(json.Number) - return ok -} - -func (rule profileJSONGateRule) MarshalYAML() (interface{}, error) { - var path any - if len(rule.Paths) == 1 { - path = rule.Paths[0] - } else { - path = append([]string(nil), rule.Paths...) - } - return struct { - ID string `yaml:"id"` - Path any `yaml:"path"` - Equals *yaml.Node `yaml:"equals,omitempty"` - Exists bool `yaml:"exists,omitempty"` - Normalize string `yaml:"normalize,omitempty"` - Fallback string `yaml:"fallback,omitempty"` - Action string `yaml:"action"` - }{rule.ID, path, rule.Equals, rule.Exists, rule.Normalize, rule.Fallback, rule.Action}, nil -} - -func (gate *ProfileScannerGate) UnmarshalYAML(node *yaml.Node) error { - node = resolvedYAMLNode(node) - if node.Kind != yaml.MappingNode { - return errors.New("scanner gate must be an object") - } - if len(node.Content) == 0 { - return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") - } - for index := 0; index < len(node.Content); index += 2 { - switch node.Content[index].Value { - case "blockOnExitCode", "warnOnExitCode", "rules": - value := resolvedYAMLNode(node.Content[index+1]) - if value.Tag == "!!null" { - return fmt.Errorf("scanner gate %s must not be null", node.Content[index].Value) - } - default: - return fmt.Errorf("field %s not found in type profiles.ProfileScannerGate", node.Content[index].Value) - } - } - type plainGate ProfileScannerGate - if err := node.Decode((*plainGate)(gate)); err != nil { - return err - } - if gate.Rules != nil && len(gate.Rules) == 0 { - return errors.New("scanner gate rules must not be empty") - } - seenRuleIDs := map[string]bool{} - for _, rule := range gate.Rules { - if seenRuleIDs[rule.ID] { - return fmt.Errorf("duplicate JSON gate rule id %s", rule.ID) - } - seenRuleIDs[rule.ID] = true - } - if gate.BlockOnExitCode == nil && gate.WarnOnExitCode == nil && len(gate.Rules) == 0 { - return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") - } - return nil -} - -func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error { - switch node.Kind { - case yaml.ScalarNode: - if err := node.Decode(&scanner.ID); err != nil { - return err - } - return nil - case yaml.MappingNode: - for index := 0; index < len(node.Content); index += 2 { - switch node.Content[index].Value { - case "id", "command", "env", "secretEnv", "targets", "gate": - if node.Content[index].Value == "command" { - scanner.custom = true - } - if node.Content[index].Value == "gate" { - gateNode := resolvedYAMLNode(node.Content[index+1]) - if gateNode.Kind != yaml.MappingNode { - return errors.New("scanner gate must be an object") - } - } - default: - return fmt.Errorf("field %s not found in type profiles.ProfileScanner", node.Content[index].Value) - } - } - var value struct { - ID string `yaml:"id"` - Command string `yaml:"command"` - Env []string `yaml:"env,omitempty"` - SecretEnv []string `yaml:"secretEnv,omitempty"` - Targets []string `yaml:"targets,omitempty"` - Gate *ProfileScannerGate `yaml:"gate,omitempty"` - } - if err := node.Decode(&value); err != nil { - return err - } - scanner.ID = value.ID - scanner.Command = value.Command - scanner.Env = value.Env - scanner.SecretEnv = value.SecretEnv - scanner.Targets = value.Targets - scanner.Gate = value.Gate - scanner.mapping = true - return nil - default: - return fmt.Errorf("scanner entry must be a string or object") - } -} - -func resolvedYAMLNode(node *yaml.Node) *yaml.Node { - for node != nil && node.Kind == yaml.AliasNode { - node = node.Alias - } - return node -} - -func (scanner ProfileScanner) MarshalYAML() (interface{}, error) { - if !scanner.mapping && !scanner.custom { - return scanner.ID, nil - } - return struct { - ID string `yaml:"id"` - Command string `yaml:"command,omitempty"` - Env []string `yaml:"env,omitempty"` - SecretEnv []string `yaml:"secretEnv,omitempty"` - Targets []string `yaml:"targets,omitempty"` - Gate *ProfileScannerGate `yaml:"gate,omitempty"` - }{scanner.ID, scanner.Command, scanner.Env, scanner.SecretEnv, scanner.Targets, scanner.Gate}, nil -} - func profileScannerIDs(scanners []ProfileScanner) []string { ids := make([]string, 0, len(scanners)) for _, scanner := range scanners { @@ -481,42 +131,6 @@ type SandboxMount struct { Write bool } -func (m *SandboxMount) UnmarshalYAML(node *yaml.Node) error { - switch node.Kind { - case yaml.ScalarNode: - return node.Decode(&m.Path) - case yaml.MappingNode: - for i := 0; i < len(node.Content); i += 2 { - switch node.Content[i].Value { - case "path", "write": - default: - return fmt.Errorf("field %s not found in type profiles.SandboxMount", node.Content[i].Value) - } - } - var v struct { - Path string `yaml:"path"` - Write bool `yaml:"write"` - } - if err := node.Decode(&v); err != nil { - return err - } - m.Path, m.Write = v.Path, v.Write - return nil - default: - return fmt.Errorf("sandbox mount must be a string or object") - } -} - -func (m SandboxMount) MarshalYAML() (interface{}, error) { - if !m.Write { - return m.Path, nil - } - return struct { - Path string `yaml:"path"` - Write bool `yaml:"write"` - }{Path: m.Path, Write: m.Write}, nil -} - type Judge struct { Command string `yaml:"command"` } diff --git a/internal/profiles/yaml.go b/internal/profiles/yaml.go new file mode 100644 index 0000000..7ca0d90 --- /dev/null +++ b/internal/profiles/yaml.go @@ -0,0 +1,416 @@ +package profiles + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/openclaw/clawscan/internal/runner" + "gopkg.in/yaml.v3" +) + +var jsonIntegerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) + +type ProfileScannerGate struct { + BlockOnExitCode *profileExitCodeRule `yaml:"blockOnExitCode,omitempty"` + WarnOnExitCode *profileExitCodeRule `yaml:"warnOnExitCode,omitempty"` + Rules []profileJSONGateRule `yaml:"rules,omitempty"` +} + +type profileJSONGateRule struct { + ID string `yaml:"id"` + Paths []string `yaml:"-"` + Equals *yaml.Node `yaml:"equals,omitempty"` + Exists bool `yaml:"exists,omitempty"` + Normalize string `yaml:"normalize,omitempty"` + Fallback string `yaml:"fallback,omitempty"` + Action string `yaml:"action"` + equalsSet bool + existsSet bool + equalsJSON json.RawMessage +} + +type profileExitCodeRule struct { + Codes []int + Nonzero bool +} + +func (rule *profileExitCodeRule) UnmarshalYAML(node *yaml.Node) error { + node = resolvedYAMLNode(node) + switch node.Kind { + case yaml.ScalarNode: + if node.Tag == "!!str" && node.Value == "nonzero" { + rule.Nonzero = true + return nil + } + if node.Tag == "!!int" { + var code int + if err := node.Decode(&code); err == nil && code >= 0 && code <= runner.MaxGateExitCode { + rule.Codes = []int{code} + return nil + } + return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) + } + case yaml.SequenceNode: + if len(node.Content) == 0 { + return errors.New("exit-code gate rule must not be an empty list") + } + codes := make([]int, 0, len(node.Content)) + for _, item := range node.Content { + item = resolvedYAMLNode(item) + if item.Kind != yaml.ScalarNode || item.Tag != "!!int" { + return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) + } + var code int + if err := item.Decode(&code); err != nil || code < 0 || code > runner.MaxGateExitCode { + return fmt.Errorf("exit-code gate rule must contain only integers from 0 through %d", runner.MaxGateExitCode) + } + codes = append(codes, code) + } + rule.Codes = codes + return nil + } + return fmt.Errorf(`exit-code gate rule must be an integer from 0 through %d, a list of those integers, or "nonzero"`, runner.MaxGateExitCode) +} + +func (rule profileExitCodeRule) MarshalYAML() (interface{}, error) { + if rule.Nonzero { + return "nonzero", nil + } + switch len(rule.Codes) { + case 0: + return nil, errors.New("exit-code gate rule must include at least one exit code") + case 1: + return rule.Codes[0], nil + default: + return append([]int(nil), rule.Codes...), nil + } +} + +func (rule *profileJSONGateRule) UnmarshalYAML(node *yaml.Node) error { + node = resolvedYAMLNode(node) + if node.Kind != yaml.MappingNode { + return errors.New("JSON gate rule must be an object") + } + seenFields := make(map[string]bool, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key := node.Content[index].Value + if seenFields[key] { + return fmt.Errorf("JSON gate rule %s has duplicate field %s", rule.ID, key) + } + seenFields[key] = true + value := resolvedYAMLNode(node.Content[index+1]) + switch key { + case "id": + if err := value.Decode(&rule.ID); err != nil { + return err + } + case "path": + if err := rule.decodePaths(value); err != nil { + return err + } + case "action": + if err := value.Decode(&rule.Action); err != nil { + return err + } + case "equals": + if err := rule.decodeEquals(value); err != nil { + return err + } + case "exists": + if value.Kind != yaml.ScalarNode || value.Tag != "!!bool" { + return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) + } + rule.existsSet = true + if err := value.Decode(&rule.Exists); err != nil { + return err + } + case "normalize": + if err := value.Decode(&rule.Normalize); err != nil { + return err + } + case "fallback": + if err := value.Decode(&rule.Fallback); err != nil { + return err + } + default: + return fmt.Errorf("field %s not found in type profiles.profileJSONGateRule", key) + } + } + return rule.validate() +} + +func (rule *profileJSONGateRule) decodePaths(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + if node.Tag != "!!str" { + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + rule.Paths = []string{node.Value} + case yaml.SequenceNode: + if len(node.Content) == 0 { + return fmt.Errorf("JSON gate rule %s path list must not be empty", rule.ID) + } + rule.Paths = make([]string, 0, len(node.Content)) + for _, pathNode := range node.Content { + pathNode = resolvedYAMLNode(pathNode) + if pathNode.Kind != yaml.ScalarNode || pathNode.Tag != "!!str" { + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + rule.Paths = append(rule.Paths, pathNode.Value) + } + default: + return fmt.Errorf("JSON gate rule %s path must be a string or list of strings", rule.ID) + } + return nil +} + +func (rule *profileJSONGateRule) decodeEquals(node *yaml.Node) error { + if node.Kind != yaml.ScalarNode || node.Tag == "!!null" { + return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) + } + switch node.Tag { + case "!!str": + rule.equalsJSON, _ = json.Marshal(node.Value) + case "!!bool": + var parsed bool + if err := node.Decode(&parsed); err != nil { + return fmt.Errorf("JSON gate rule %s equals must be a boolean", rule.ID) + } + rule.equalsJSON, _ = json.Marshal(parsed) + case "!!int": + if !validJSONGateNumber(node.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) + } + if !jsonIntegerPattern.MatchString(node.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a JSON integer", rule.ID) + } + rule.equalsJSON = append(json.RawMessage(nil), node.Value...) + case "!!float": + if !validJSONGateNumber(node.Value) { + return fmt.Errorf("JSON gate rule %s equals must be a finite JSON number", rule.ID) + } + rule.equalsJSON = append(json.RawMessage(nil), node.Value...) + default: + return fmt.Errorf("JSON gate rule %s equals must be a string, number, or boolean", rule.ID) + } + rule.Equals = node + rule.equalsSet = true + return nil +} + +func (rule profileJSONGateRule) validate() error { + if strings.TrimSpace(rule.ID) == "" { + return errors.New("JSON gate rule id must not be empty") + } + if len(rule.Paths) == 0 { + return fmt.Errorf("JSON gate rule %s path must not be empty", rule.ID) + } + seenPaths := map[string]bool{} + for _, path := range rule.Paths { + if err := runner.ValidateJSONGatePath(path); err != nil { + return fmt.Errorf("JSON gate rule %s path %q is invalid: %w", rule.ID, path, err) + } + if seenPaths[path] { + return fmt.Errorf("JSON gate rule %s has duplicate path %q", rule.ID, path) + } + seenPaths[path] = true + } + if rule.Action != "warn" && rule.Action != "block" { + return fmt.Errorf("JSON gate rule %s action must be warn or block", rule.ID) + } + if rule.existsSet && !rule.Exists { + return fmt.Errorf("JSON gate rule %s exists must be true", rule.ID) + } + if rule.equalsSet == rule.existsSet { + return fmt.Errorf("JSON gate rule %s must include exactly one of equals or exists: true", rule.ID) + } + if rule.Normalize != "" && rule.Normalize != "identifier" { + return fmt.Errorf("JSON gate rule %s normalize must be identifier", rule.ID) + } + if rule.Normalize != "" && (!rule.equalsSet || rule.Equals.Tag != "!!str") { + return fmt.Errorf("JSON gate rule %s normalize requires a string equals value", rule.ID) + } + if rule.Fallback != "" && rule.Fallback != "root" { + return fmt.Errorf("JSON gate rule %s fallback must be root", rule.ID) + } + return nil +} + +func validJSONGateNumber(value string) bool { + if !json.Valid([]byte(value)) { + return false + } + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.UseNumber() + var parsed any + if err := decoder.Decode(&parsed); err != nil { + return false + } + _, ok := parsed.(json.Number) + return ok +} + +func (rule profileJSONGateRule) MarshalYAML() (interface{}, error) { + var path any + if len(rule.Paths) == 1 { + path = rule.Paths[0] + } else { + path = append([]string(nil), rule.Paths...) + } + return struct { + ID string `yaml:"id"` + Path any `yaml:"path"` + Equals *yaml.Node `yaml:"equals,omitempty"` + Exists bool `yaml:"exists,omitempty"` + Normalize string `yaml:"normalize,omitempty"` + Fallback string `yaml:"fallback,omitempty"` + Action string `yaml:"action"` + }{rule.ID, path, rule.Equals, rule.Exists, rule.Normalize, rule.Fallback, rule.Action}, nil +} + +func (gate *ProfileScannerGate) UnmarshalYAML(node *yaml.Node) error { + node = resolvedYAMLNode(node) + if node.Kind != yaml.MappingNode { + return errors.New("scanner gate must be an object") + } + if len(node.Content) == 0 { + return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") + } + for index := 0; index < len(node.Content); index += 2 { + switch node.Content[index].Value { + case "blockOnExitCode", "warnOnExitCode", "rules": + value := resolvedYAMLNode(node.Content[index+1]) + if value.Tag == "!!null" { + return fmt.Errorf("scanner gate %s must not be null", node.Content[index].Value) + } + default: + return fmt.Errorf("field %s not found in type profiles.ProfileScannerGate", node.Content[index].Value) + } + } + type plainGate ProfileScannerGate + if err := node.Decode((*plainGate)(gate)); err != nil { + return err + } + if gate.Rules != nil && len(gate.Rules) == 0 { + return errors.New("scanner gate rules must not be empty") + } + seenRuleIDs := map[string]bool{} + for _, rule := range gate.Rules { + if seenRuleIDs[rule.ID] { + return fmt.Errorf("duplicate JSON gate rule id %s", rule.ID) + } + seenRuleIDs[rule.ID] = true + } + if gate.BlockOnExitCode == nil && gate.WarnOnExitCode == nil && len(gate.Rules) == 0 { + return errors.New("scanner gate must include blockOnExitCode, warnOnExitCode, or rules") + } + return nil +} + +func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + if err := node.Decode(&scanner.ID); err != nil { + return err + } + return nil + case yaml.MappingNode: + for index := 0; index < len(node.Content); index += 2 { + switch node.Content[index].Value { + case "id", "command", "env", "secretEnv", "targets", "gate": + if node.Content[index].Value == "command" { + scanner.custom = true + } + if node.Content[index].Value == "gate" { + gateNode := resolvedYAMLNode(node.Content[index+1]) + if gateNode.Kind != yaml.MappingNode { + return errors.New("scanner gate must be an object") + } + } + default: + return fmt.Errorf("field %s not found in type profiles.ProfileScanner", node.Content[index].Value) + } + } + var value struct { + ID string `yaml:"id"` + Command string `yaml:"command"` + Env []string `yaml:"env,omitempty"` + SecretEnv []string `yaml:"secretEnv,omitempty"` + Targets []string `yaml:"targets,omitempty"` + Gate *ProfileScannerGate `yaml:"gate,omitempty"` + } + if err := node.Decode(&value); err != nil { + return err + } + scanner.ID = value.ID + scanner.Command = value.Command + scanner.Env = value.Env + scanner.SecretEnv = value.SecretEnv + scanner.Targets = value.Targets + scanner.Gate = value.Gate + scanner.mapping = true + return nil + default: + return fmt.Errorf("scanner entry must be a string or object") + } +} + +func (scanner ProfileScanner) MarshalYAML() (interface{}, error) { + if !scanner.mapping && !scanner.custom { + return scanner.ID, nil + } + return struct { + ID string `yaml:"id"` + Command string `yaml:"command,omitempty"` + Env []string `yaml:"env,omitempty"` + SecretEnv []string `yaml:"secretEnv,omitempty"` + Targets []string `yaml:"targets,omitempty"` + Gate *ProfileScannerGate `yaml:"gate,omitempty"` + }{scanner.ID, scanner.Command, scanner.Env, scanner.SecretEnv, scanner.Targets, scanner.Gate}, nil +} + +func (m *SandboxMount) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + return node.Decode(&m.Path) + case yaml.MappingNode: + for index := 0; index < len(node.Content); index += 2 { + switch node.Content[index].Value { + case "path", "write": + default: + return fmt.Errorf("field %s not found in type profiles.SandboxMount", node.Content[index].Value) + } + } + var value struct { + Path string `yaml:"path"` + Write bool `yaml:"write"` + } + if err := node.Decode(&value); err != nil { + return err + } + m.Path, m.Write = value.Path, value.Write + return nil + default: + return fmt.Errorf("sandbox mount must be a string or object") + } +} + +func (m SandboxMount) MarshalYAML() (interface{}, error) { + if !m.Write { + return m.Path, nil + } + return struct { + Path string `yaml:"path"` + Write bool `yaml:"write"` + }{Path: m.Path, Write: m.Write}, nil +} + +func resolvedYAMLNode(node *yaml.Node) *yaml.Node { + for node != nil && node.Kind == yaml.AliasNode { + node = node.Alias + } + return node +} diff --git a/schemas/clawscan.schema.json b/schemas/clawscan.schema.json new file mode 100644 index 0000000..19914e8 --- /dev/null +++ b/schemas/clawscan.schema.json @@ -0,0 +1,363 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/openclaw/clawscan/main/schemas/clawscan.schema.json", + "title": "ClawScan configuration", + "description": "Configuration for ClawScan profiles, scanners, sandboxing, judges, and declarative gate policy.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "profiles" + ], + "properties": { + "version": { + "const": 1 + }, + "sandbox": { + "$ref": "#/$defs/sandbox" + }, + "profiles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/profile" + } + } + }, + "$defs": { + "profile": { + "type": "object", + "additionalProperties": false, + "properties": { + "scanners": { + "type": "array", + "items": { + "$ref": "#/$defs/scanner" + } + }, + "scannerResults": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "output": { + "type": "string" + }, + "json": { + "type": "boolean" + }, + "sandbox": { + "$ref": "#/$defs/sandbox" + }, + "judge": { + "$ref": "#/$defs/judge" + } + } + }, + "scanner": { + "oneOf": [ + { + "$ref": "#/$defs/builtinScannerID", + "description": "Registered built-in scanner ID." + }, + { + "$ref": "#/$defs/builtinScanner" + }, + { + "$ref": "#/$defs/commandScanner" + } + ] + }, + "builtinScannerID": { + "enum": [ + "agentverus", + "aig", + "cisco", + "clawscan-static", + "relyable", + "skillspector", + "snyk", + "socket", + "virustotal" + ] + }, + "builtinScanner": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "$ref": "#/$defs/builtinScannerID", + "description": "Registered built-in scanner ID." + }, + "gate": { + "$ref": "#/$defs/scannerGate" + } + } + }, + "commandScanner": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "command" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "not": { + "$ref": "#/$defs/builtinScannerID" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\{\\{\\s*target\\s*\\}\\}", + "description": "Scanner command containing an active, unquoted {{target}} placeholder." + }, + "env": { + "$ref": "#/$defs/environmentNames" + }, + "secretEnv": { + "$ref": "#/$defs/environmentNames" + }, + "targets": { + "type": "array", + "items": { + "enum": [ + "skill", + "plugin", + "url" + ] + } + }, + "gate": { + "$ref": "#/$defs/scannerGate" + } + } + }, + "scannerGate": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "blockOnExitCode" + ] + }, + { + "required": [ + "warnOnExitCode" + ] + }, + { + "required": [ + "rules" + ] + } + ], + "properties": { + "blockOnExitCode": { + "$ref": "#/$defs/exitCodeRule" + }, + "warnOnExitCode": { + "$ref": "#/$defs/exitCodeRule" + }, + "rules": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/jsonGateRule" + } + } + } + }, + "exitCodeRule": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 124 + }, + { + "const": "nonzero" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 124 + } + } + ] + }, + "jsonGateRule": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "path", + "action" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "path": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "equals": { + "type": [ + "string", + "number", + "boolean" + ] + }, + "exists": { + "const": true + }, + "normalize": { + "const": "identifier" + }, + "fallback": { + "const": "root" + }, + "action": { + "enum": [ + "warn", + "block" + ] + } + }, + "allOf": [ + { + "oneOf": [ + { + "required": [ + "equals" + ], + "not": { + "required": [ + "exists" + ] + } + }, + { + "required": [ + "exists" + ], + "not": { + "required": [ + "equals" + ] + } + } + ] + }, + { + "if": { + "required": [ + "normalize" + ] + }, + "then": { + "required": [ + "equals" + ], + "properties": { + "equals": { + "type": "string" + } + } + } + } + ] + }, + "sandbox": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "docker", + "off" + ] + }, + "image": { + "type": "string" + }, + "env": { + "$ref": "#/$defs/environmentNames" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/$defs/sandboxMount" + } + } + } + }, + "sandboxMount": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "write": { + "type": "boolean" + } + } + } + ] + }, + "environmentNames": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "judge": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + } + } + } + } +} diff --git a/schemas/clawscan.schema_test.go b/schemas/clawscan.schema_test.go new file mode 100644 index 0000000..4f783f4 --- /dev/null +++ b/schemas/clawscan.schema_test.go @@ -0,0 +1,219 @@ +package schemas_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/openclaw/clawscan/internal/runner" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" +) + +func TestClawScanSchemaAcceptsSupportedProfiles(t *testing.T) { + schema := compileClawScanSchema(t) + fixtures := map[string][]byte{ + "embedded clawhub profile": readFixture(t, filepath.Join("..", "internal", "profiles", "clawhub", "clawscan.yml")), + "custom scanner and gate": []byte(` +version: 1 +sandbox: + mode: docker + image: ghcr.io/openclaw/clawscan-runtime:latest + env: [OPENAI_API_KEY] + mounts: + - /opt/rules + - path: /var/cache/clawscan + write: true +profiles: + review: + scanners: + - skillspector + - id: clawscan-static + gate: + rules: + - id: any-finding + path: findings[] + exists: true + action: warn + - id: third-party + command: third-party scan --json {{target}} + env: [THIRD_PARTY_REGION] + secretEnv: [THIRD_PARTY_TOKEN] + targets: [skill, plugin, url] + gate: + blockOnExitCode: nonzero + warnOnExitCode: [1, 2] + rules: + - id: critical-risk + path: + - result.risk + - result.risk_level + equals: critical + normalize: identifier + fallback: root + action: block + scannerResults: + skillspector: ./fixtures/skillspector.json + output: ./artifacts/review.json + json: true + judge: + command: judge --input {{ workspace }} --output {{ output }} +`), + } + + for name, data := range fixtures { + t.Run(name, func(t *testing.T) { + if err := schema.Validate(decodeYAMLAsJSON(t, data)); err != nil { + t.Fatalf("schema rejected supported profile: %v", err) + } + }) + } +} + +func TestClawScanSchemaAcceptsEveryBuiltInScanner(t *testing.T) { + schema := compileClawScanSchema(t) + for _, scannerID := range runner.DefaultScannerRegistry().IDs() { + t.Run(scannerID, func(t *testing.T) { + data := []byte("version: 1\nprofiles:\n review:\n scanners:\n - " + scannerID + "\n") + if err := schema.Validate(decodeYAMLAsJSON(t, data)); err != nil { + t.Fatalf("schema rejected built-in scanner %q: %v", scannerID, err) + } + }) + } +} + +func TestClawScanSchemaRejectsInvalidGateRules(t *testing.T) { + schema := compileClawScanSchema(t) + tests := map[string]string{ + "unknown field": ` + - id: risky + path: result.risk + equals: critical + aciton: block +`, + "invalid action": ` + - id: risky + path: result.risk + equals: critical + action: deny +`, + "false exists": ` + - id: risky + path: result.risk + exists: false + action: block +`, + "both conditions": ` + - id: risky + path: result.risk + equals: critical + exists: true + action: block +`, + "numeric normalization": ` + - id: risky + path: result.score + equals: 10 + normalize: identifier + action: block +`, + "empty path list": ` + - id: risky + path: [] + exists: true + action: block +`, + } + + for name, rules := range tests { + t.Run(name, func(t *testing.T) { + data := []byte("version: 1\nprofiles:\n review:\n scanners:\n - id: demo\n command: demo {{target}}\n gate:\n rules:\n" + rules) + if err := schema.Validate(decodeYAMLAsJSON(t, data)); err == nil { + t.Fatal("schema accepted invalid gate rule") + } + }) + } +} + +func TestClawScanSchemaRejectsInvalidScannerDeclarations(t *testing.T) { + schema := compileClawScanSchema(t) + tests := map[string]string{ + "unknown built-in": "not-a-scanner", + "custom scanner without target": ` + id: custom + command: custom scan +`, + "built-in scanner overridden as custom": ` + id: snyk + command: custom scan {{target}} +`, + } + + for name, scanner := range tests { + t.Run(name, func(t *testing.T) { + data := []byte("version: 1\nprofiles:\n review:\n scanners:\n - " + scanner) + if err := schema.Validate(decodeYAMLAsJSON(t, data)); err == nil { + t.Fatal("schema accepted invalid scanner declaration") + } + }) + } +} + +func compileClawScanSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + data := readFixture(t, "clawscan.schema.json") + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode schema: %v", err) + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource("clawscan.schema.json", document); err != nil { + t.Fatalf("add schema resource: %v", err) + } + schema, err := compiler.Compile("clawscan.schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + return schema +} + +func decodeYAMLAsJSON(t *testing.T, data []byte) any { + t.Helper() + var value any + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(false) + if err := decoder.Decode(&value); err != nil { + t.Fatalf("decode YAML fixture: %v", err) + } + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("encode YAML fixture as JSON: %v", err) + } + jsonDecoder := json.NewDecoder(bytes.NewReader(encoded)) + jsonDecoder.UseNumber() + if err := jsonDecoder.Decode(&value); err != nil { + t.Fatalf("decode normalized JSON fixture: %v", err) + } + return value +} + +func readFixture(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return data +} + +func TestProfileDocsAdvertiseThePublishedSchema(t *testing.T) { + docs := readFixture(t, filepath.Join("..", "docs", "profiles.md")) + const directive = "# yaml-language-server: $schema=https://raw.githubusercontent.com/openclaw/clawscan/main/schemas/clawscan.schema.json" + if !strings.Contains(string(docs), directive) { + t.Fatalf("profile docs do not include schema directive %q", directive) + } +} From 373a5348c959cd378763197567324a225f41255f Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:41:35 +1000 Subject: [PATCH 16/28] fix(plugin): harden install gate integration --- .github/workflows/npm-release.yml | 59 ++++--- .github/workflows/release.yml | 17 +- npm/clawscan-plugin/README.md | 17 +- npm/clawscan-plugin/openclaw.plugin.json | 1 + npm/clawscan-plugin/src/artifact.ts | 138 +++++++++++++--- npm/clawscan-plugin/src/gate-handler.ts | 18 +- npm/clawscan-plugin/test/artifact.test.ts | 154 ++++++++++++++---- npm/clawscan-plugin/test/gate-handler.test.ts | 28 +++- npm/clawscan-plugin/test/package.test.mjs | 3 + scripts/build-npm-package.mjs | 4 +- scripts/build-npm-package.test.mjs | 27 +++ 11 files changed, 386 insertions(+), 80 deletions(-) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 3e8b47b..2910d6b 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -212,28 +212,43 @@ jobs: package_name="@openclaw/clawscan" tarball_path="${{ steps.publish_tarball.outputs.path }}" published_version="" - if published_version="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then - if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then - echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 - exit 1 - fi - remote_integrity="" - local_integrity="" - remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity)" - # shellcheck disable=SC2016 - local_integrity="$(node --input-type=module -e ' - import { createHash } from "node:crypto"; - import { readFileSync } from "node:fs"; - const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); - process.stdout.write(`sha512-${digest}`); - ' "$tarball_path")" - if [[ "$remote_integrity" != "$local_integrity" ]]; then - echo "${package_name}@${PACKAGE_VERSION} does not match the prepared release tarball." >&2 - exit 1 - fi - echo "${package_name}@${PACKAGE_VERSION} is already published with valid release metadata; skipping publish." - echo "clawscan_needed=false" >> "$GITHUB_OUTPUT" - exit 0 + view_output="" + view_status=0 + set +e + view_output="$(npm view "${package_name}@${PACKAGE_VERSION}" version 2>&1)" + view_status=$? + set -e + if [[ "$view_status" -eq 0 && -n "$view_output" ]]; then + published_version="$view_output" + if [[ "$published_version" != "$PACKAGE_VERSION" ]]; then + echo "${package_name}@${PACKAGE_VERSION} reported unexpected version ${published_version}." >&2 + exit 1 + fi + remote_integrity="" + local_integrity="" + remote_integrity="$(npm view "${package_name}@${PACKAGE_VERSION}" dist.integrity)" + # shellcheck disable=SC2016 + local_integrity="$(node --input-type=module -e ' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + const digest = createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"); + process.stdout.write(`sha512-${digest}`); + ' "$tarball_path")" + if [[ "$remote_integrity" != "$local_integrity" ]]; then + echo "${package_name}@${PACKAGE_VERSION} does not match the prepared release tarball." >&2 + exit 1 + fi + echo "${package_name}@${PACKAGE_VERSION} is already published with valid release metadata; skipping publish." + echo "clawscan_needed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$view_status" -eq 0 ]]; then + echo "${package_name}@${PACKAGE_VERSION} returned an empty publish-state response." >&2 + exit 1 + fi + if ! printf '%s\n' "$view_output" | grep -q "E404"; then + printf '%s\n' "$view_output" >&2 + exit "$view_status" fi echo "${package_name}@${PACKAGE_VERSION} is not published yet." echo "clawscan_needed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe5400b..846d8d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,7 +212,14 @@ jobs: local tarball_path="$2" local output_name="$3" local published_version="" - if published_version="$(npm view "${package_name}@${package_version}" version 2>/dev/null)" && [[ -n "$published_version" ]]; then + local view_output="" + local view_status=0 + set +e + view_output="$(npm view "${package_name}@${package_version}" version 2>&1)" + view_status=$? + set -e + if [[ "$view_status" -eq 0 && -n "$view_output" ]]; then + published_version="$view_output" local remote_integrity="" local local_integrity="" remote_integrity="$(npm view "${package_name}@${package_version}" dist.integrity)" @@ -231,6 +238,14 @@ jobs: echo "${output_name}=false" >> "$GITHUB_OUTPUT" return fi + if [[ "$view_status" -eq 0 ]]; then + echo "${package_name}@${package_version} returned an empty publish-state response." >&2 + exit 1 + fi + if ! printf '%s\n' "$view_output" | grep -q "E404"; then + printf '%s\n' "$view_output" >&2 + return "$view_status" + fi echo "${output_name}=true" >> "$GITHUB_OUTPUT" } inspect_package \ diff --git a/npm/clawscan-plugin/README.md b/npm/clawscan-plugin/README.md index f0bee9d..eae7db8 100644 --- a/npm/clawscan-plugin/README.md +++ b/npm/clawscan-plugin/README.md @@ -3,16 +3,23 @@ `@openclaw/clawscan-plugin` registers OpenClaw's `before_install` hook and fails closed when ClawScan cannot produce a trustworthy gate artifact. -Install the plugin, then explicitly trust and enable it: +This package requires OpenClaw's +[cold install-provider contract](https://github.com/openclaw/openclaw/pull/115197), +which discovers explicitly trusted `before_install` providers before both CLI +and Gateway install/update operations. Earlier prerelease builds that only run +hooks already loaded in the current process are not supported. + +Install the plugin through OpenClaw: ```sh openclaw plugins install @openclaw/clawscan-plugin -openclaw plugins enable clawscan ``` -This writes `plugins.entries.clawscan.enabled=true`. If your OpenClaw -configuration uses `plugins.allow`, add `clawscan` to that list as well. -Installation alone does not activate this install hook. +That operator action explicitly trusts and enables this config-free plugin by +writing `plugins.entries.clawscan.enabled=true` and adding `clawscan` to +`plugins.allow` when the allowlist is configured. If the package is placed by +another mechanism, run `openclaw plugins enable clawscan` and ensure the +allowlist includes `clawscan` before relying on the install hook. By default, every candidate skill or plugin is scanned with SkillSpector (`CLAWSCAN_SKILLSPECTOR_LLM=0`) and `clawscan-static` inside ClawScan's Docker diff --git a/npm/clawscan-plugin/openclaw.plugin.json b/npm/clawscan-plugin/openclaw.plugin.json index 7257d21..878f6e4 100644 --- a/npm/clawscan-plugin/openclaw.plugin.json +++ b/npm/clawscan-plugin/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "clawscan", "activation": { "onStartup": false, + "onHooks": ["before_install"], "onCapabilities": ["hook"] }, "name": "ClawScan Install Gate", diff --git a/npm/clawscan-plugin/src/artifact.ts b/npm/clawscan-plugin/src/artifact.ts index 77a1fef..1530b82 100644 --- a/npm/clawscan-plugin/src/artifact.ts +++ b/npm/clawscan-plugin/src/artifact.ts @@ -19,7 +19,7 @@ function isRecord(value: unknown): value is Record { } function scannerCompleted(value: unknown): value is Record { - return isRecord(value) && value.status === "completed" && cleanText(value.error, 1) === ""; + return isRecord(value) && value.status === "completed"; } function skillSpectorEvidenceUsable(raw: unknown): boolean { @@ -99,29 +99,125 @@ function cleanFindingLine(value: unknown): number { return Math.min(1_000_000, Math.max(1, Math.trunc(value))); } -function findingFromRule(rule: Record): InstallFinding | undefined { - if ( - typeof rule.scanner !== "string" || - typeof rule.rule !== "string" || - (rule.action !== "warn" && rule.action !== "block") - ) { - return undefined; +function firstText( + record: Record, + keys: readonly string[], + limit: number, +): string { + for (const key of keys) { + const value = cleanText(record[key], limit); + if (value !== "") { + return value; + } + } + return ""; +} + +function firstNumber(record: Record, keys: readonly string[]): number { + for (const key of keys) { + if (typeof record[key] === "number") { + return cleanFindingLine(record[key]); + } } + return 1; +} + +function normalizeIdentifier(value: string): string { + return value.trim().toUpperCase().replaceAll(" ", "_").replaceAll("-", "_"); +} + +function evidenceRecordsForRule( + scanner: string, + raw: unknown, + rule: Record, +): Record[] { + if (!isRecord(raw)) { + return []; + } + const path = cleanText(rule.path, 240); + const pathRoot = path.includes("[]") ? path.slice(0, path.indexOf("[]")) : ""; + const keys = + scanner === "clawscan-static" + ? ["findings"] + : ["filtered_findings", "filteredFindings", "findings", "issues", "vulnerabilities"]; + const orderedKeys = pathRoot === "" ? keys : [pathRoot, ...keys.filter((key) => key !== pathRoot)]; + for (const key of orderedKeys) { + const value = raw[key]; + if (Array.isArray(value)) { + return value.filter(isRecord); + } + } + return []; +} + +function findingFromEvidence( + rule: Record, + evidence: Record, +): InstallFinding { const scanner = cleanRuleSegment(rule.scanner, "unknown-scanner"); - const ruleName = cleanRuleSegment(rule.findingCode ?? rule.rule, "gate-rule"); + const evidenceId = firstText(evidence, ["id", "rule_id", "ruleId", "issueId", "code"], 64); + const ruleName = cleanRuleSegment(evidenceId || rule.rule, "gate-rule"); const title = - cleanText(rule.findingTitle, 240) || - `${cleanText(rule.scanner, 80)} fired ${cleanText(rule.rule, 80)}`; - const severity = cleanText(rule.findingSeverity, 40); + firstText(evidence, ["title", "description", "explanation", "message"], 240) || + `${cleanText(rule.scanner, 80)} finding ${evidenceId || cleanText(rule.rule, 80)}`; + const severity = firstText(evidence, ["severity", "risk_severity", "riskSeverity", "level"], 40); return { ruleId: `clawscan/${scanner}/${ruleName}`, severity: rule.action === "block" ? "critical" : "warn", - file: cleanFindingFile(rule.file), - line: cleanFindingLine(rule.line), + file: cleanFindingFile(firstText(evidence, ["path", "file_path", "filePath", "file"], 1_000)), + line: firstNumber(evidence, ["line", "start_line", "startLine"]), message: severity ? `${severity}: ${title}` : title, }; } +function findingFromRule(rule: Record): InstallFinding { + const scanner = cleanRuleSegment(rule.scanner, "unknown-scanner"); + const ruleName = cleanRuleSegment(rule.rule, "gate-rule"); + const path = cleanText(rule.path, 240); + const value = + rule.value === undefined ? "" : cleanText(JSON.stringify(rule.value), 120); + const matched = path === "" ? "" : ` matched ${path}${value === "" ? "" : `=${value}`}`; + const title = `${cleanText(rule.scanner, 80)} fired ${cleanText(rule.rule, 80)}${matched}`; + return { + ruleId: `clawscan/${scanner}/${ruleName}`, + severity: rule.action === "block" ? "critical" : "warn", + file: ".", + line: 1, + message: title, + }; +} + +function findingsFromRule( + rule: Record, + scanners: Record, +): InstallFinding[] | undefined { + if ( + typeof rule.scanner !== "string" || + typeof rule.rule !== "string" || + (rule.action !== "warn" && rule.action !== "block") + ) { + return undefined; + } + const scannerResult = scanners[rule.scanner]; + const raw = isRecord(scannerResult) ? scannerResult.raw : undefined; + const expectedSeverity = typeof rule.value === "string" ? normalizeIdentifier(rule.value) : ""; + const evidence = evidenceRecordsForRule(rule.scanner, raw, rule).filter((entry) => { + if (expectedSeverity === "") { + return true; + } + const severity = firstText( + entry, + ["severity", "risk_severity", "riskSeverity", "level"], + 40, + ); + return severity !== "" && normalizeIdentifier(severity) === expectedSeverity; + }); + if (evidence.length === 0) { + return [findingFromRule(rule)]; + } + return evidence.map((entry) => findingFromEvidence(rule, entry)); +} + function ruleReferencesAvailableScanner( rule: Record, scanners: Record, @@ -202,11 +298,12 @@ export function gateResultFromArtifact( if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); } - const finding = findingFromRule(rule); - if (!finding) { + const ruleFindings = findingsFromRule(rule, parsed.scanners); + if (!ruleFindings) { return blockForInvalidArtifact("warn artifact contained an invalid fired gate rule"); } - findings.push(finding); + findings.push(...ruleFindings); + findings.length = Math.min(findings.length, MAX_GATE_RULES); } if (findings.length === 0) { return blockForInvalidArtifact("warn artifact did not contain a fired warning rule"); @@ -222,11 +319,12 @@ export function gateResultFromArtifact( if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); } - const finding = findingFromRule(rule); - if (!finding) { + const ruleFindings = findingsFromRule(rule, parsed.scanners); + if (!ruleFindings) { return blockForInvalidArtifact("block artifact contained an invalid fired gate rule"); } - findings.push(finding); + findings.push(...ruleFindings); + findings.length = Math.min(findings.length, MAX_GATE_RULES); } const blockingMessages = findings .filter((finding) => finding.severity === "critical") diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts index 238f808..f2de532 100644 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ b/npm/clawscan-plugin/src/gate-handler.ts @@ -122,9 +122,25 @@ const degradedFinding = { message: "Gate degraded: Docker mode unavailable on this host; clawscan-static only.", }; +// The host command runner merges overrides with its ambient environment. Empty +// values prevent ClawScan from forwarding provider credentials into Docker. +const noLlmEnvironment = { + CLAWSCAN_SKILLSPECTOR_LLM: "0", + SKILLSPECTOR_PROVIDER: "", + SKILLSPECTOR_MODEL: "", + SKILLSPECTOR_MODEL_REGISTRY: "", + NVIDIA_INFERENCE_KEY: "", + OPENAI_API_KEY: "", + OPENAI_BASE_URL: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_PROXY_ENDPOINT_URL: "", + ANTHROPIC_PROXY_API_KEY: "", + ANTHROPIC_PROXY_API_VERSION: "", +}; + const scanCommandOptions: CommandOptions = { timeoutMs: SCAN_TIMEOUT_MS, - env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + env: noLlmEnvironment, killProcessTree: true, maxOutputBytes: { stdout: MAX_STDOUT_BYTES, diff --git a/npm/clawscan-plugin/test/artifact.test.ts b/npm/clawscan-plugin/test/artifact.test.ts index afb948d..d4ea3aa 100644 --- a/npm/clawscan-plugin/test/artifact.test.ts +++ b/npm/clawscan-plugin/test/artifact.test.ts @@ -39,24 +39,50 @@ describe("gateResultFromArtifact", () => { gateRules: [ { scanner: "skillspector", - rule: "nativeFindingSeverity", - findingCode: "SS-101", - findingTitle: "Suspicious package script", - findingSeverity: "HIGH", + rule: "high-finding", + path: "filtered_findings[].severity", + value: "HIGH", action: "warn", }, { scanner: "clawscan-static", - rule: "nativeFinding", - findingCode: "prompt-injection", - findingTitle: "Prompt injection language", - findingSeverity: "high", + rule: "any-finding", + path: "findings[]", action: "warn", }, ], scanners: { - skillspector: skillSpectorCompleted, - "clawscan-static": staticCompleted, + skillspector: { + status: "completed", + error: "", + raw: { + filtered_findings: [ + { + rule_id: "SS-101", + severity: "HIGH", + file_path: "package.json", + start_line: 12, + description: "Suspicious package script", + }, + ], + }, + }, + "clawscan-static": { + status: "completed", + error: "", + raw: { + schemaVersion: "clawscan-static-v1", + findings: [ + { + id: "prompt-injection", + severity: "high", + path: "SKILL.md", + line: 4, + title: "Prompt injection language", + }, + ], + }, + }, }, }), ["skillspector", "clawscan-static"], @@ -67,21 +93,71 @@ describe("gateResultFromArtifact", () => { { ruleId: "clawscan/skillspector/SS-101", severity: "warn", - file: ".", - line: 1, + file: "package.json", + line: 12, message: "HIGH: Suspicious package script", }, { ruleId: "clawscan/clawscan-static/prompt-injection", severity: "warn", - file: ".", - line: 1, + file: "SKILL.md", + line: 4, message: "high: Prompt injection language", }, ], }); }); + it("evaluates valid evidence from a completed scanner with a nonzero-exit error", () => { + const result = gateResultFromArtifact( + JSON.stringify({ + schemaVersion: "clawscan-run-v1", + gate: "warn", + gateRules: [ + { + scanner: "skillspector", + rule: "high-finding", + path: "issues[].severity", + value: "HIGH", + action: "warn", + }, + ], + scanners: { + skillspector: { + status: "completed", + error: "scanner exited with code 1", + exitCode: 1, + raw: { + risk_assessment: { severity: "HIGH" }, + issues: [ + { + id: "SS-101", + severity: "HIGH", + path: "package.json", + line: 12, + description: "Suspicious package script", + }, + ], + }, + }, + }, + }), + ["skillspector"], + ); + + assert.deepEqual(result, { + findings: [ + { + ruleId: "clawscan/skillspector/SS-101", + severity: "warn", + file: "package.json", + line: 12, + message: "HIGH: Suspicious package script", + }, + ], + }); + }); + it("maps a block artifact to an explicit block with its fired findings", () => { const result = gateResultFromArtifact( JSON.stringify({ @@ -90,15 +166,28 @@ describe("gateResultFromArtifact", () => { gateRules: [ { scanner: "skillspector", - rule: "nativeFindingSeverity", - findingCode: "SS-900", - findingTitle: "Credential theft behavior", - findingSeverity: "CRITICAL", + rule: "critical-finding", + path: "filtered_findings[].severity", + value: "CRITICAL", action: "block", }, ], scanners: { - skillspector: skillSpectorCompleted, + skillspector: { + status: "completed", + error: "", + raw: { + filtered_findings: [ + { + rule_id: "SS-900", + severity: "CRITICAL", + file_path: "SKILL.md", + start_line: 9, + description: "Credential theft behavior", + }, + ], + }, + }, "clawscan-static": staticCompleted, }, }), @@ -112,8 +201,8 @@ describe("gateResultFromArtifact", () => { { ruleId: "clawscan/skillspector/SS-900", severity: "critical", - file: ".", - line: 1, + file: "SKILL.md", + line: 9, message: "CRITICAL: Credential theft behavior", }, ], @@ -128,17 +217,26 @@ describe("gateResultFromArtifact", () => { gateRules: [ { scanner: "demo scanner\u0000", - rule: "nativeFinding", - findingCode: "odd rule/id", - findingTitle: `unsafe\u0000 title ${"x".repeat(400)}`, - findingSeverity: "HIGH", - file: "/../../private/\u0000token.ts", - line: 9_999_999, + rule: "any-finding", + path: "findings[]", action: "warn", }, ], scanners: { - "demo scanner\u0000": { status: "completed" }, + "demo scanner\u0000": { + status: "completed", + raw: { + findings: [ + { + id: "odd rule/id", + title: `unsafe\u0000 title ${"x".repeat(400)}`, + severity: "HIGH", + path: "/../../private/\u0000token.ts", + line: 9_999_999, + }, + ], + }, + }, }, }), ["demo scanner\u0000"], diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts index c31296f..fb36651 100644 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ b/npm/clawscan-plugin/test/gate-handler.test.ts @@ -87,7 +87,19 @@ describe("createBeforeInstallHandler", () => { ], options: { timeoutMs: 600_000, - env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + env: { + CLAWSCAN_SKILLSPECTOR_LLM: "0", + SKILLSPECTOR_PROVIDER: "", + SKILLSPECTOR_MODEL: "", + SKILLSPECTOR_MODEL_REGISTRY: "", + NVIDIA_INFERENCE_KEY: "", + OPENAI_API_KEY: "", + OPENAI_BASE_URL: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_PROXY_ENDPOINT_URL: "", + ANTHROPIC_PROXY_API_KEY: "", + ANTHROPIC_PROXY_API_VERSION: "", + }, killProcessTree: true, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, outputCapture: "head", @@ -150,7 +162,19 @@ describe("createBeforeInstallHandler", () => { ], options: { timeoutMs: 600_000, - env: { CLAWSCAN_SKILLSPECTOR_LLM: "0" }, + env: { + CLAWSCAN_SKILLSPECTOR_LLM: "0", + SKILLSPECTOR_PROVIDER: "", + SKILLSPECTOR_MODEL: "", + SKILLSPECTOR_MODEL_REGISTRY: "", + NVIDIA_INFERENCE_KEY: "", + OPENAI_API_KEY: "", + OPENAI_BASE_URL: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_PROXY_ENDPOINT_URL: "", + ANTHROPIC_PROXY_API_KEY: "", + ANTHROPIC_PROXY_API_VERSION: "", + }, killProcessTree: true, maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, outputCapture: "head", diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs index a29b314..3216049 100644 --- a/npm/clawscan-plugin/test/package.test.mjs +++ b/npm/clawscan-plugin/test/package.test.mjs @@ -23,8 +23,11 @@ describe("@openclaw/clawscan-plugin package", () => { assert.equal(packageJson.peerDependenciesMeta.openclaw.optional, true); assert.deepEqual(packageJson.openclaw.extensions, ["./index.ts"]); assert.equal(packageJson.openclaw.install.npmSpec, "@openclaw/clawscan-plugin"); + assert.equal(packageJson.openclaw.install.minHostVersion, ">=2026.7.2"); + assert.equal(packageJson.openclaw.compat.pluginApi, ">=2026.7.2"); assert.equal(manifest.id, "clawscan"); assert.equal(manifest.activation.onStartup, false); + assert.deepEqual(manifest.activation.onHooks, ["before_install"]); assert.deepEqual(manifest.activation.onCapabilities, ["hook"]); assert.equal(manifest.enabledByDefault, undefined); }); diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 0d670ba..4cc0489 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -269,7 +269,9 @@ async function smokePackages( if ( installedPackageJson.version !== packageVersion || installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion || - installedPackageJson.peerDependencies?.openclaw !== ">=2026.7.2" + installedPackageJson.peerDependencies?.openclaw !== ">=2026.7.2" || + installedPackageJson.openclaw?.install?.minHostVersion !== ">=2026.7.2" || + installedPackageJson.openclaw?.compat?.pluginApi !== ">=2026.7.2" ) { throw new Error("Installed ClawScan plugin did not preserve its host and binary contracts."); } diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index 5ecd498..b8a8159 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { binaryNameForTarget, @@ -67,6 +68,26 @@ describe("package target mapping", () => { }); }); +describe("GitHub release target mapping", () => { + it("builds the complete supported archive matrix", () => { + const releaseScript = readFileSync(new URL("./build-release.sh", import.meta.url), "utf8"); + const matrix = releaseScript.match(/platforms=\(\n(?(?:\s+"[^"]+"\n)+)\)/u); + + assert.ok(matrix?.groups?.entries, "release platform matrix was not found"); + assert.deepEqual( + [...matrix.groups.entries.matchAll(/"([^"]+)"/gu)].map((match) => match[1]), + [ + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64", + "windows/amd64", + "windows/arm64", + ], + ); + }); +}); + describe("preparePluginPackageJson", () => { it("pins the plugin and its binary dependency to the exact release version", () => { assert.deepEqual( @@ -75,6 +96,10 @@ describe("preparePluginPackageJson", () => { name: "@openclaw/clawscan-plugin", version: "0.0.0-dev", dependencies: { "@openclaw/clawscan": "0.0.0-dev" }, + openclaw: { + install: { minHostVersion: ">=2026.7.2" }, + compat: { pluginApi: ">=2026.7.2" }, + }, }, "1.2.3", ), @@ -84,6 +109,8 @@ describe("preparePluginPackageJson", () => { files: ["dist/"], dependencies: { "@openclaw/clawscan": "1.2.3" }, openclaw: { + install: { minHostVersion: ">=2026.7.2" }, + compat: { pluginApi: ">=2026.7.2" }, runtimeExtensions: ["./dist/index.js"], }, }, From 5c5a00030ee5e350479dd34cae653a69c30e9681 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:10:03 +1000 Subject: [PATCH 17/28] fix(plugin): defer publishing until host contract ships --- .github/workflows/release.yml | 22 ++++++++++------------ npm/clawscan-plugin/README.md | 11 +++++++++-- npm/clawscan-plugin/package.json | 22 +++------------------- npm/clawscan-plugin/test/package.test.mjs | 11 ++++++----- scripts/build-npm-package.mjs | 13 +++++++++---- scripts/build-npm-package.test.mjs | 8 ++++---- 6 files changed, 41 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 846d8d2..0e9dc3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: --generate-notes publish-npm: - name: Publish ClawScan npm packages + name: Publish ClawScan npm package runs-on: ubuntu-latest needs: build if: github.event_name == 'push' || inputs.publish @@ -163,8 +163,14 @@ jobs: if (pluginPkg.dependencies?.[expectedName] !== pluginPkg.version) { errors.push(`plugin must depend on the exact matching ${expectedName} version`); } - if (pluginPkg.private === true) { - errors.push("plugin package must not be private"); + if (pluginPkg.private !== true) { + errors.push("plugin package must remain private until its host contract is released"); + } + if ( + pluginPkg.openclaw?.release?.publishToNpm !== false || + pluginPkg.openclaw?.release?.publishToClawHub !== false + ) { + errors.push("plugin publication must remain disabled until its host contract is released"); } if (errors.length > 0) { for (const error of errors) console.error(error); @@ -252,24 +258,16 @@ jobs: "@openclaw/clawscan" \ "dist/npm/openclaw-clawscan-${package_version}.tgz" \ "clawscan_needed" - inspect_package \ - "@openclaw/clawscan-plugin" \ - "dist/npm/openclaw-clawscan-plugin-${package_version}.tgz" \ - "plugin_needed" echo "package_version=$package_version" >> "$GITHUB_OUTPUT" - name: Publish ClawScan binary package if: steps.publish_state.outputs.clawscan_needed == 'true' run: npm publish "dist/npm/openclaw-clawscan-${{ steps.publish_state.outputs.package_version }}.tgz" --access public --provenance - - name: Publish ClawScan OpenClaw plugin - if: steps.publish_state.outputs.plugin_needed == 'true' - run: npm publish "dist/npm/openclaw-clawscan-plugin-${{ steps.publish_state.outputs.package_version }}.tgz" --access public --provenance - - name: Verify npm release metadata run: | set -euo pipefail - for package_name in "@openclaw/clawscan" "@openclaw/clawscan-plugin"; do + for package_name in "@openclaw/clawscan"; do npm_dist_json="" for attempt in {1..12}; do if npm_dist_json="$(npm view "${package_name}@${{ steps.publish_state.outputs.package_version }}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$npm_dist_json" ]]; then diff --git a/npm/clawscan-plugin/README.md b/npm/clawscan-plugin/README.md index eae7db8..cf3cd17 100644 --- a/npm/clawscan-plugin/README.md +++ b/npm/clawscan-plugin/README.md @@ -9,13 +9,20 @@ which discovers explicitly trusted `before_install` providers before both CLI and Gateway install/update operations. Earlier prerelease builds that only run hooks already loaded in the current process are not supported. -Install the plugin through OpenClaw: +The host contract has not shipped in an OpenClaw release. This package is +therefore a private preview and is intentionally excluded from npm and ClawHub +publication. Once a supporting host release exists, the package metadata must +be updated to name that release as the minimum supported version before +publication is enabled. + +After that release boundary is defined and this package is published, install +the plugin through OpenClaw: ```sh openclaw plugins install @openclaw/clawscan-plugin ``` -That operator action explicitly trusts and enables this config-free plugin by +That future operator action explicitly trusts and enables this config-free plugin by writing `plugins.entries.clawscan.enabled=true` and adding `clawscan` to `plugins.allow` when the allowlist is configured. If the package is placed by another mechanism, run `openclaw plugins enable clawscan` and ensure the diff --git a/npm/clawscan-plugin/package.json b/npm/clawscan-plugin/package.json index b763e98..6e20845 100644 --- a/npm/clawscan-plugin/package.json +++ b/npm/clawscan-plugin/package.json @@ -1,6 +1,7 @@ { "name": "@openclaw/clawscan-plugin", "version": "0.0.0-dev", + "private": true, "description": "Fail-closed ClawScan install gate for OpenClaw skills and plugins.", "homepage": "https://github.com/openclaw/clawscan#openclaw-install-gate", "bugs": { @@ -30,14 +31,6 @@ "dependencies": { "@openclaw/clawscan": "0.0.0-dev" }, - "peerDependencies": { - "openclaw": ">=2026.7.2" - }, - "peerDependenciesMeta": { - "openclaw": { - "optional": true - } - }, "engines": { "node": ">=22.22.3" }, @@ -45,22 +38,13 @@ "extensions": [ "./index.ts" ], - "install": { - "clawhubSpec": "clawhub:@openclaw/clawscan-plugin", - "npmSpec": "@openclaw/clawscan-plugin", - "defaultChoice": "npm", - "minHostVersion": ">=2026.7.2" - }, - "compat": { - "pluginApi": ">=2026.7.2" - }, "build": { "openclawVersion": "2026.7.2", "bundledDist": false }, "release": { - "publishToClawHub": true, - "publishToNpm": true, + "publishToClawHub": false, + "publishToNpm": false, "bundleRuntimeDependencies": false } } diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs index 3216049..2a5468b 100644 --- a/npm/clawscan-plugin/test/package.test.mjs +++ b/npm/clawscan-plugin/test/package.test.mjs @@ -18,13 +18,14 @@ describe("@openclaw/clawscan-plugin package", () => { assert.equal(packageJson.name, "@openclaw/clawscan-plugin"); assert.equal(packageJson.version, "0.0.0-dev"); + assert.equal(packageJson.private, true); assert.equal(packageJson.dependencies["@openclaw/clawscan"], packageJson.version); - assert.equal(packageJson.peerDependencies.openclaw, ">=2026.7.2"); - assert.equal(packageJson.peerDependenciesMeta.openclaw.optional, true); + assert.equal(packageJson.peerDependencies, undefined); assert.deepEqual(packageJson.openclaw.extensions, ["./index.ts"]); - assert.equal(packageJson.openclaw.install.npmSpec, "@openclaw/clawscan-plugin"); - assert.equal(packageJson.openclaw.install.minHostVersion, ">=2026.7.2"); - assert.equal(packageJson.openclaw.compat.pluginApi, ">=2026.7.2"); + assert.equal(packageJson.openclaw.install, undefined); + assert.equal(packageJson.openclaw.compat, undefined); + assert.equal(packageJson.openclaw.release.publishToClawHub, false); + assert.equal(packageJson.openclaw.release.publishToNpm, false); assert.equal(manifest.id, "clawscan"); assert.equal(manifest.activation.onStartup, false); assert.deepEqual(manifest.activation.onHooks, ["before_install"]); diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 4cc0489..4b8d027 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -269,11 +269,16 @@ async function smokePackages( if ( installedPackageJson.version !== packageVersion || installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion || - installedPackageJson.peerDependencies?.openclaw !== ">=2026.7.2" || - installedPackageJson.openclaw?.install?.minHostVersion !== ">=2026.7.2" || - installedPackageJson.openclaw?.compat?.pluginApi !== ">=2026.7.2" + installedPackageJson.private !== true || + installedPackageJson.peerDependencies !== undefined || + installedPackageJson.openclaw?.install !== undefined || + installedPackageJson.openclaw?.compat !== undefined || + installedPackageJson.openclaw?.release?.publishToNpm !== false || + installedPackageJson.openclaw?.release?.publishToClawHub !== false ) { - throw new Error("Installed ClawScan plugin did not preserve its host and binary contracts."); + throw new Error( + "Installed ClawScan plugin did not preserve its private preview and binary contracts.", + ); } await readFile(join(installedPluginRoot, "openclaw.plugin.json"), "utf8"); await readFile(join(installedPluginRoot, "profiles", "clawhub.yml"), "utf8"); diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index b8a8159..f4e5395 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -95,10 +95,10 @@ describe("preparePluginPackageJson", () => { { name: "@openclaw/clawscan-plugin", version: "0.0.0-dev", + private: true, dependencies: { "@openclaw/clawscan": "0.0.0-dev" }, openclaw: { - install: { minHostVersion: ">=2026.7.2" }, - compat: { pluginApi: ">=2026.7.2" }, + release: { publishToClawHub: false, publishToNpm: false }, }, }, "1.2.3", @@ -106,11 +106,11 @@ describe("preparePluginPackageJson", () => { { name: "@openclaw/clawscan-plugin", version: "1.2.3", + private: true, files: ["dist/"], dependencies: { "@openclaw/clawscan": "1.2.3" }, openclaw: { - install: { minHostVersion: ">=2026.7.2" }, - compat: { pluginApi: ">=2026.7.2" }, + release: { publishToClawHub: false, publishToNpm: false }, runtimeExtensions: ["./dist/index.js"], }, }, From 787c28f65e3486d7ad70270d18e377799883cd42 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:31:35 +1000 Subject: [PATCH 18/28] feat(policy): add OpenClaw install policy adapter --- .../workflows/clawscan-plugin-self-scan.yml | 47 -- .github/workflows/npm-release.yml | 4 +- .github/workflows/release.yml | 20 +- README.md | 13 + cmd/clawscan/main.go | 171 +++++++ cmd/clawscan/main_test.go | 298 ++++++++++++ docs/index.md | 8 + docs/openclaw-install-policy.md | 135 ++++++ docs/profiles.md | 1 + internal/installpolicy/policy.go | 422 +++++++++++++++++ internal/installpolicy/policy_test.go | 368 ++++++++++++++ internal/installpolicy/stages.go | 373 +++++++++++++++ internal/installpolicy/stages_test.go | 327 +++++++++++++ .../openclaw-install-policy/clawscan.yml | 9 +- internal/profiles/registry_test.go | 2 +- internal/profiles/resolver.go | 3 +- internal/profiles/resolver_test.go | 2 +- internal/runner/runner.go | 3 +- internal/runner/target.go | 27 ++ internal/runner/target_test.go | 22 + npm/clawscan-plugin/README.md | 48 -- npm/clawscan-plugin/index.ts | 10 - npm/clawscan-plugin/openclaw.plugin.json | 26 - npm/clawscan-plugin/package.json | 51 -- npm/clawscan-plugin/src/artifact.ts | 345 -------------- npm/clawscan-plugin/src/gate-handler.ts | 228 --------- npm/clawscan-plugin/src/register.ts | 81 ---- npm/clawscan-plugin/test/artifact.test.ts | 419 ---------------- npm/clawscan-plugin/test/gate-handler.test.ts | 448 ------------------ npm/clawscan-plugin/test/package.test.mjs | 81 ---- npm/clawscan-plugin/test/registration.test.ts | 93 ---- scripts/build-docs-site.mjs | 3 +- scripts/build-npm-package.mjs | 154 +----- scripts/build-npm-package.test.mjs | 48 +- 34 files changed, 2188 insertions(+), 2102 deletions(-) delete mode 100644 .github/workflows/clawscan-plugin-self-scan.yml create mode 100644 docs/openclaw-install-policy.md create mode 100644 internal/installpolicy/policy.go create mode 100644 internal/installpolicy/policy_test.go create mode 100644 internal/installpolicy/stages.go create mode 100644 internal/installpolicy/stages_test.go rename npm/clawscan-plugin/profiles/clawhub.yml => internal/profiles/openclaw-install-policy/clawscan.yml (92%) delete mode 100644 npm/clawscan-plugin/README.md delete mode 100644 npm/clawscan-plugin/index.ts delete mode 100644 npm/clawscan-plugin/openclaw.plugin.json delete mode 100644 npm/clawscan-plugin/package.json delete mode 100644 npm/clawscan-plugin/src/artifact.ts delete mode 100644 npm/clawscan-plugin/src/gate-handler.ts delete mode 100644 npm/clawscan-plugin/src/register.ts delete mode 100644 npm/clawscan-plugin/test/artifact.test.ts delete mode 100644 npm/clawscan-plugin/test/gate-handler.test.ts delete mode 100644 npm/clawscan-plugin/test/package.test.mjs delete mode 100644 npm/clawscan-plugin/test/registration.test.ts diff --git a/.github/workflows/clawscan-plugin-self-scan.yml b/.github/workflows/clawscan-plugin-self-scan.yml deleted file mode 100644 index 31e9a9a..0000000 --- a/.github/workflows/clawscan-plugin-self-scan.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: ClawScan Plugin Self-Scan - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - scan: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Scan the OpenClaw plugin - env: - CLAWSCAN_SKILLSPECTOR_LLM: "0" - run: | - set -euo pipefail - artifact="${RUNNER_TEMP}/clawscan-plugin-artifact.json" - go run ./cmd/clawscan ./npm/clawscan-plugin \ - --config ./npm/clawscan-plugin/profiles/clawhub.yml \ - --profile clawhub \ - --sandbox docker \ - --json \ - --output "$artifact" - # shellcheck disable=SC2016 - node --input-type=module -e ' - import { readFileSync } from "node:fs"; - const artifact = JSON.parse(readFileSync(process.argv[1], "utf8")); - const required = ["skillspector", "clawscan-static"]; - if (artifact.schemaVersion !== "clawscan-run-v1" || artifact.gate !== "pass") { - throw new Error(`ClawScan plugin self-scan gate was ${artifact.gate ?? "invalid"}`); - } - for (const scanner of required) { - if (artifact.scanners?.[scanner]?.status !== "completed") { - throw new Error(`Required self-scan scanner ${scanner} did not complete`); - } - } - ' "$artifact" diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 2910d6b..fec865d 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -1,4 +1,3 @@ -# Plugin trusted publishing intentionally lives only in release.yml. name: ClawScan Binary NPM Promotion on: @@ -61,10 +60,9 @@ jobs: - name: Test npm packaging helpers run: | node --test npm/clawscan/test/*.test.mjs - node --test npm/clawscan-plugin/test/*.test.mjs npm/clawscan-plugin/test/*.test.ts node --test scripts/build-npm-package.test.mjs - - name: Build and smoke packed npm packages + - name: Build and smoke packed npm package run: node scripts/build-npm-package.mjs --version "${{ inputs.tag }}" --pack --smoke - name: Upload prepared npm publish bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e9dc3d..0c5c6f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,9 +133,7 @@ jobs: const { readFileSync } = require("node:fs"); const pkg = JSON.parse(readFileSync("npm/clawscan/package.json", "utf8")); - const pluginPkg = JSON.parse(readFileSync("npm/clawscan-plugin/package.json", "utf8")); const expectedName = "@openclaw/clawscan"; - const expectedPluginName = "@openclaw/clawscan-plugin"; const expectedRepo = "https://github.com/openclaw/clawscan"; const repository = typeof pkg.repository === "string" ? pkg.repository @@ -157,21 +155,6 @@ jobs: if (pkg.private === true) { errors.push("package must not be private"); } - if (pluginPkg.name !== expectedPluginName) { - errors.push(`plugin package name must be ${expectedPluginName}; found ${pluginPkg.name ?? ""}`); - } - if (pluginPkg.dependencies?.[expectedName] !== pluginPkg.version) { - errors.push(`plugin must depend on the exact matching ${expectedName} version`); - } - if (pluginPkg.private !== true) { - errors.push("plugin package must remain private until its host contract is released"); - } - if ( - pluginPkg.openclaw?.release?.publishToNpm !== false || - pluginPkg.openclaw?.release?.publishToClawHub !== false - ) { - errors.push("plugin publication must remain disabled until its host contract is released"); - } if (errors.length > 0) { for (const error of errors) console.error(error); process.exit(1); @@ -199,10 +182,9 @@ jobs: execFileSync("git", ["merge-base", "--is-ancestor", releaseSha, "origin/main"]); NODE - - name: Check npm packages + - name: Check npm package run: | node --test npm/clawscan/test/*.test.mjs - node --test npm/clawscan-plugin/test/*.test.mjs npm/clawscan-plugin/test/*.test.ts node --test scripts/build-npm-package.test.mjs node scripts/build-npm-package.mjs --version "${{ needs.build.outputs.version }}" --pack --smoke diff --git a/README.md b/README.md index e440faf..5f4b420 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ ClawScan turns that approach into a repeatable CLI. It includes a built-in `claw | `clawscan profiles [-v]` | Inspect built-in profiles; `-v` prints the catalog as YAML. | | `clawscan benchmark [list\|]` | Discover or run supported benchmarks through a selected scanner/profile/judge setup. | | `clawscan install [...]` | Install or verify local scanner dependencies where ClawScan has registry-backed install plans. | +| `clawscan openclaw-install-policy` | Act as an external OpenClaw `security.installPolicy.exec` command. Reads the staged install request from stdin and returns allow/block JSON. | + +## OpenClaw install policy + +ClawScan integrates with OpenClaw at the operator-owned +`security.installPolicy` boundary. It does not register an install hook or +depend on plugin activation. The policy command scans the staged `sourcePath` +for both skills and plugins before OpenClaw commits a supported install or +update. + +See [docs/openclaw-install-policy.md](docs/openclaw-install-policy.md) for the +trusted executable setup, configuration, payload contract, and scope. ## Scanners @@ -187,6 +199,7 @@ clawscan profiles -v | Profile | Scanners | Judge | | --- | --- | --- | | `clawhub` | `skillspector`, `clawscan-static` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema | +| `openclaw-install-policy` | `skillspector`, `clawscan-static` | none | ### Build a custom profile with `.clawscan.yml` diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 13065f9..da18918 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -7,9 +7,11 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "text/tabwriter" + "github.com/openclaw/clawscan/internal/installpolicy" "github.com/openclaw/clawscan/internal/profiles" "github.com/openclaw/clawscan/internal/runner" ) @@ -50,6 +52,9 @@ func run(args []string, environ []string) error { if len(args) > 0 && args[0] == "install" { return runInstall(args[1:], environ) } + if len(args) > 0 && args[0] == "openclaw-install-policy" { + return runOpenClawInstallPolicy(args[1:], environ, os.Stdin, os.Stdout) + } if len(args) > 0 && looksLikeCommand(args[0]) { return fmt.Errorf("Unknown command: %s", args[0]) } @@ -105,6 +110,166 @@ func run(args []string, environ []string) error { return nil } +func runOpenClawInstallPolicy( + args []string, + environ []string, + input io.Reader, + output io.Writer, +) error { + failClosed := func(err error) error { + return installpolicy.WriteResponse(output, installpolicy.FailureResponse(err.Error())) + } + request, err := installpolicy.DecodeRequest(input) + if err != nil { + return failClosed(err) + } + cwd, err := os.Getwd() + if err != nil { + return failClosed(err) + } + if !hasProfileSelection(args) { + args = append(args, "--profile", "openclaw-install-policy") + } + resolved, err := profiles.ResolveRunSet(append([]string{request.SourcePath}, args...), cwd) + if err != nil { + return failClosed(err) + } + if resolved.AllProfiles || len(resolved.Options) != 1 { + return failClosed(errors.New("OpenClaw install policy requires exactly one ClawScan profile")) + } + opts := resolved.Options[0] + opts.TargetKind = request.TargetType + opts.JSON = false + opts.OutputPath = "" + metadataPreflight := request.IsNPMMetadataPreflight() + if metadataPreflight { + if err := installpolicy.ValidateNPMMetadataPreflight(request); err != nil { + return failClosed(err) + } + } + metadataStaticOnly := applyInstallPolicyMetadataDefaults(&opts, args, metadataPreflight) + windowsDegraded := false + if !metadataStaticOnly { + windowsDegraded = applyInstallPolicyPlatformDefaults(&opts, args, runtime.GOOS) + } + if request.IsDependencyTree() { + scanTarget, cleanup, empty, err := installpolicy.PrepareDependencyTreeScanTarget( + request.SourcePath, + request.AllowsManagedNPMRootPeerLinks(), + ) + if err != nil { + return failClosed(err) + } + defer cleanup() + if empty { + response := installpolicy.Response{ProtocolVersion: 1, Decision: "allow"} + installpolicy.AddFinding(&response, installpolicy.Finding{ + RuleID: "clawscan.empty-dependency-tree", + Severity: "info", + Message: "OpenClaw reported no installed runtime dependencies in this dependency-tree phase.", + }) + return installpolicy.WriteResponse(output, response) + } + opts.Target = scanTarget + } + result, err := runner.RunTargets(opts, runner.RunContext{Env: runner.EnvMap(environ)}, cwd) + if err != nil { + return failClosed(err) + } + if result.Single == nil || result.Batch != nil { + return failClosed(errors.New("ClawScan install policy expected one scan artifact")) + } + response := installpolicy.ResponseFromArtifact(*result.Single) + if metadataPreflight { + installpolicy.AddFinding(&response, installpolicy.Finding{ + RuleID: "clawscan.npm-metadata-preflight", + Severity: "info", + Message: "ClawScan validated npm registry metadata in this preflight phase; OpenClaw submits the resolved package and dependency tree for separate code scans.", + }) + } + if windowsDegraded { + installpolicy.AddFinding(&response, installpolicy.Finding{ + RuleID: "clawscan.windows-static-fallback", + Severity: "warn", + Message: "Docker scanning is unavailable in the native Windows policy path; ClawScan used static analysis only.", + }) + } + return installpolicy.WriteResponse(output, response) +} + +func hasProfileSelection(args []string) bool { + for _, arg := range args { + if arg == "--profile" || strings.HasPrefix(arg, "--profile=") || + arg == "--config" || strings.HasPrefix(arg, "--config=") { + return true + } + } + return false +} + +func applyInstallPolicyPlatformDefaults(opts *runner.Options, args []string, goos string) bool { + if goos != "windows" || + opts.Profile != "openclaw-install-policy" || + opts.ConfigSource != "built-in" || + hasInstallPolicyConfigOverride(args) { + return false + } + for _, arg := range args { + if arg == "--scanner" || strings.HasPrefix(arg, "--scanner=") || + arg == "--sandbox" || strings.HasPrefix(arg, "--sandbox=") { + return false + } + } + opts.Scanners = []string{"clawscan-static"} + keepInstallPolicyGateRules(opts, "clawscan-static") + opts.Sandbox.Mode = runner.SandboxModeOff + return true +} + +func applyInstallPolicyMetadataDefaults( + opts *runner.Options, + args []string, + metadataPreflight bool, +) bool { + if !metadataPreflight || + opts.Profile != "openclaw-install-policy" || + opts.ConfigSource != "built-in" || + hasInstallPolicyExecutionOverride(args) || hasInstallPolicyConfigOverride(args) { + return false + } + opts.Scanners = []string{"clawscan-static"} + keepInstallPolicyGateRules(opts, "clawscan-static") + opts.Sandbox.Mode = runner.SandboxModeOff + return true +} + +func keepInstallPolicyGateRules(opts *runner.Options, scannerID string) { + for configuredScannerID := range opts.GateRules { + if configuredScannerID != scannerID { + delete(opts.GateRules, configuredScannerID) + } + } +} + +func hasInstallPolicyExecutionOverride(args []string) bool { + for _, arg := range args { + if arg == "--scanner" || strings.HasPrefix(arg, "--scanner=") || + arg == "--sandbox" || strings.HasPrefix(arg, "--sandbox=") { + return true + } + } + return false +} + +func hasInstallPolicyConfigOverride(args []string) bool { + for _, arg := range args { + if arg == "--config" || strings.HasPrefix(arg, "--config=") { + return true + } + } + return false +} + func runBenchmarkCommand(args []string, environ []string) error { switch { case len(args) == 1 && args[0] == "list": @@ -585,6 +750,7 @@ Usage: clawscan benchmark list clawscan benchmark --scanner [flags] clawscan benchmark --profile [flags] + clawscan openclaw-install-policy [--profile ] [flags] clawscan --scanner [flags] clawscan --scanner [flags] clawscan --profile clawhub [flags] @@ -608,6 +774,11 @@ Core flags: --sandbox-env Allow an env var through the Docker sandbox. Repeat for multiple vars. --sandbox-mount Bind-mount a host path into the Docker sandbox (read-only; append :rw for writable). Repeat for multiple. +OpenClaw install policy: + openclaw-install-policy Read an OpenClaw security.installPolicy request from stdin and + return its protocol v1 allow/block response on stdout. + Defaults to the composable openclaw-install-policy profile. + Benchmark command flags: --split Benchmark split. Defaults to benchmark for SkillTrustBench and eval_holdout for clawhub-security-signals. --ids Run selected benchmark IDs from a text file or JSONL id source. SkillTrustBench only. diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 635aa27..1a2434f 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "io" @@ -27,6 +28,7 @@ func TestRunCommandPrintsHelp(t *testing.T) { "clawscan benchmark list", "clawscan benchmark --scanner [flags]", "clawscan benchmark --profile [flags]", + "clawscan openclaw-install-policy [--profile ] [flags]", "clawscan --scanner [flags]", "clawscan --scanner [flags]", "clawscan --profile clawhub [flags]", @@ -86,6 +88,302 @@ func TestRunCommandPrintsHelp(t *testing.T) { } } +func TestRunOpenClawInstallPolicyScansSkillAndPluginTargets(t *testing.T) { + tests := []struct { + name string + targetType string + sourceKind string + filename string + content string + }{ + { + name: "skill directory", + targetType: "skill", + sourceKind: "directory", + filename: "SKILL.md", + content: "# Safe skill\n", + }, + { + name: "plugin file", + targetType: "plugin", + sourceKind: "file", + filename: "plugin.js", + content: "export default {};\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + sourcePath := dir + if test.sourceKind == "file" { + sourcePath = filepath.Join(dir, test.filename) + } else { + writeFile(t, filepath.Join(dir, test.filename), test.content) + } + if test.sourceKind == "file" { + writeFile(t, sourcePath, test.content) + } + request := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":%q, + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":%q, + "origin":{"type":"test"}, + "request":{"kind":%q,"mode":"install"}%s + }`, test.targetType, sourcePath, test.sourceKind, map[string]string{ + "skill": "skill-install", + "plugin": "plugin-file", + }[test.targetType], map[string]string{ + "skill": "", + "plugin": `,"plugin":{"pluginId":"demo","contentType":"file","extensions":["plugin.js"]}`, + }[test.targetType]) + var output bytes.Buffer + if err := runOpenClawInstallPolicy( + []string{"--scanner", "clawscan-static", "--sandbox", "off"}, + []string{}, + strings.NewReader(request), + &output, + ); err != nil { + t.Fatal(err) + } + var response struct { + ProtocolVersion int `json:"protocolVersion"` + Decision string `json:"decision"` + } + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.ProtocolVersion != 1 || response.Decision != "allow" { + t.Fatalf("response = %s", output.String()) + } + }) + } +} + +func TestRunOpenClawInstallPolicyFailsClosedWithValidResponse(t *testing.T) { + var output bytes.Buffer + if err := runOpenClawInstallPolicy( + nil, + nil, + strings.NewReader(`{"protocolVersion":2}`), + &output, + ); err != nil { + t.Fatal(err) + } + var response struct { + ProtocolVersion int `json:"protocolVersion"` + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.ProtocolVersion != 1 || response.Decision != "block" || + !strings.Contains(response.Reason, "failed closed") { + t.Fatalf("response = %s", output.String()) + } +} + +func TestRunOpenClawInstallPolicyHandlesNPMInstallStagesSeparately(t *testing.T) { + dir := t.TempDir() + metadataPath := filepath.Join(dir, "npm-package-metadata.json") + writeFile(t, metadataPath, `{ + "packageName":"@acme/demo", + "requestedSpecifier":"@acme/demo@1.2.3", + "resolution":{"name":"@acme/demo","version":"1.2.3"} + }`) + metadataRequest := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":"plugin", + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":"file", + "source":{"kind":"npm","authority":"third-party","mutable":false,"network":true}, + "origin":{"type":"plugin-npm","packageName":"@acme/demo"}, + "request":{"kind":"plugin-npm","mode":"install","requestedSpecifier":"@acme/demo@1.2.3"}, + "plugin":{"pluginId":"demo","contentType":"package","packageName":"@acme/demo"} + }`, metadataPath) + metadataResponse := runInstallPolicyTestRequest(t, nil, metadataRequest) + if metadataResponse.Decision != "allow" || + !hasInstallPolicyFinding(metadataResponse.Findings, "clawscan.npm-metadata-preflight") { + t.Fatalf("metadata response = %#v", metadataResponse) + } + + packageDir := filepath.Join(dir, "resolved-package") + writeFile(t, filepath.Join(packageDir, "package.json"), `{"name":"@acme/demo"}`) + writeFile(t, filepath.Join(packageDir, "index.js"), "export default true") + packageRequest := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":"plugin", + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":"directory", + "source":{"kind":"npm","authority":"third-party","mutable":false,"network":true}, + "origin":{"type":"plugin-package"}, + "request":{"kind":"plugin-npm","mode":"install","requestedSpecifier":"@acme/demo@1.2.3"}, + "plugin":{"pluginId":"demo","contentType":"package","packageName":"@acme/demo"} + }`, packageDir) + staticArgs := []string{"--scanner", "clawscan-static", "--sandbox", "off"} + packageResponse := runInstallPolicyTestRequest(t, staticArgs, packageRequest) + if packageResponse.Decision != "allow" || + hasInstallPolicyFinding(packageResponse.Findings, "clawscan.npm-metadata-preflight") { + t.Fatalf("package response = %#v", packageResponse) + } + + dependencyRoot := filepath.Join(dir, "managed-root") + dependencyDir := filepath.Join(dependencyRoot, "node_modules", "transitive") + writeFile(t, filepath.Join(dependencyDir, "package.json"), `{"name":"transitive"}`) + writeFile(t, filepath.Join(dependencyDir, "payload.js"), "ignore previous instructions") + dependencyRequest := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":"plugin", + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":"directory", + "source":{"kind":"npm","authority":"third-party","mutable":false,"network":true}, + "origin":{"type":"plugin-dependency-tree"}, + "request":{"kind":"plugin-npm","mode":"install","requestedSpecifier":"@acme/demo@1.2.3"}, + "plugin":{"pluginId":"demo","contentType":"dependency-tree"} + }`, dependencyRoot) + dependencyResponse := runInstallPolicyTestRequest(t, staticArgs, dependencyRequest) + if dependencyResponse.Decision != "allow" || len(dependencyResponse.Findings) == 0 { + t.Fatalf("dependency response did not expose transitive code to the static gate: %#v", dependencyResponse) + } + + emptyDependencyRoot := filepath.Join(dir, "dependency-free-package") + writeFile(t, filepath.Join(emptyDependencyRoot, "package.json"), `{"name":"dependency-free"}`) + emptyDependencyRequest := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":"plugin", + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":"directory", + "source":{"kind":"local-path","authority":"user","mutable":true,"network":false}, + "origin":{"type":"plugin-dependency-tree"}, + "request":{"kind":"plugin-dir","mode":"install","requestedSpecifier":%q}, + "plugin":{"pluginId":"demo","contentType":"dependency-tree"} + }`, emptyDependencyRoot, emptyDependencyRoot) + emptyDependencyResponse := runInstallPolicyTestRequest(t, staticArgs, emptyDependencyRequest) + if emptyDependencyResponse.Decision != "allow" || + !hasInstallPolicyFinding( + emptyDependencyResponse.Findings, + "clawscan.empty-dependency-tree", + ) { + t.Fatalf("empty dependency response = %#v", emptyDependencyResponse) + } +} + +func TestApplyInstallPolicyPlatformDefaultsUsesVisibleWindowsStaticFallback(t *testing.T) { + opts := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: "built-in", + Scanners: []string{"skillspector", "clawscan-static"}, + } + if !applyInstallPolicyPlatformDefaults(&opts, nil, "windows") { + t.Fatal("expected Windows fallback") + } + if len(opts.Scanners) != 1 || opts.Scanners[0] != "clawscan-static" { + t.Fatalf("scanners = %#v", opts.Scanners) + } + if opts.Sandbox.Mode != runner.SandboxModeOff { + t.Fatalf("sandbox mode = %q", opts.Sandbox.Mode) + } + + explicit := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: "built-in", + Scanners: []string{"skillspector", "clawscan-static"}, + } + if applyInstallPolicyPlatformDefaults(&explicit, []string{"--sandbox", "docker"}, "windows") { + t.Fatal("explicit execution options must not be overridden") + } + + shadowed := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: filepath.Join(t.TempDir(), ".clawscan.yml"), + Scanners: []string{"operator-scanner"}, + } + if applyInstallPolicyPlatformDefaults(&shadowed, nil, "windows") { + t.Fatal("operator-owned profile shadow must not be overridden") + } +} + +func TestApplyInstallPolicyMetadataDefaultsUsesStaticOnlyForDefaultPreflight(t *testing.T) { + opts := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: "built-in", + Scanners: []string{"skillspector", "clawscan-static"}, + } + if !applyInstallPolicyMetadataDefaults(&opts, nil, true) { + t.Fatal("expected metadata preflight defaults") + } + if len(opts.Scanners) != 1 || opts.Scanners[0] != "clawscan-static" { + t.Fatalf("scanners = %#v", opts.Scanners) + } + if opts.Sandbox.Mode != runner.SandboxModeOff { + t.Fatalf("sandbox mode = %q", opts.Sandbox.Mode) + } + + explicit := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: "built-in", + Scanners: []string{"skillspector", "clawscan-static"}, + } + if applyInstallPolicyMetadataDefaults(&explicit, []string{"--scanner", "skillspector"}, true) { + t.Fatal("explicit execution options must not be overridden") + } + + shadowed := runner.Options{ + Profile: "openclaw-install-policy", + ConfigSource: filepath.Join(t.TempDir(), ".clawscan.yml"), + Scanners: []string{"operator-scanner"}, + } + if applyInstallPolicyMetadataDefaults(&shadowed, nil, true) { + t.Fatal("operator-owned profile shadow must not be overridden") + } +} + +type installPolicyTestResponse struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + Findings []struct { + RuleID string `json:"ruleId"` + } `json:"findings"` +} + +func runInstallPolicyTestRequest( + t *testing.T, + args []string, + request string, +) installPolicyTestResponse { + t.Helper() + var output bytes.Buffer + if err := runOpenClawInstallPolicy(args, nil, strings.NewReader(request), &output); err != nil { + t.Fatal(err) + } + var response installPolicyTestResponse + if err := json.Unmarshal(output.Bytes(), &response); err != nil { + t.Fatalf("decode response %q: %v", output.String(), err) + } + return response +} + +func hasInstallPolicyFinding( + findings []struct { + RuleID string `json:"ruleId"` + }, + ruleID string, +) bool { + for _, finding := range findings { + if finding.RuleID == ruleID { + return true + } + } + return false +} + func TestRunCommandInstallStaticScannerPrintsSkippedStatus(t *testing.T) { stdout := captureStdout(t, func() { if err := run([]string{"install", "clawscan-static"}, []string{}); err != nil { diff --git a/docs/index.md b/docs/index.md index 2a4cf19..6e3e602 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,3 +111,11 @@ ClawScan turns that approach into a repeatable CLI. It includes a built-in `claw | `clawscan profiles [-v]` | Inspect built-in profiles; `-v` prints the catalog as YAML. | | `clawscan benchmark [list\|]` | Discover or run supported benchmarks through a selected scanner/profile/judge setup. | | `clawscan install [...]` | Install or verify local scanner dependencies where ClawScan has registry-backed install plans. | +| `clawscan openclaw-install-policy` | Run as an external OpenClaw `security.installPolicy.exec` command for staged skill and plugin installs. | + +## OpenClaw install policy + +ClawScan integrates through OpenClaw's operator-owned +`security.installPolicy` boundary, not a plugin-runtime install hook. See +[OpenClaw install policy](openclaw-install-policy.md) for trusted executable +setup and the fail-closed request/response contract. diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md new file mode 100644 index 0000000..d6d709a --- /dev/null +++ b/docs/openclaw-install-policy.md @@ -0,0 +1,135 @@ +# OpenClaw install policy + +ClawScan can run as OpenClaw's external `security.installPolicy.exec` command. +This is an operator-owned boundary. It does not require a ClawScan plugin, +plugin activation, or a new install hook. + +OpenClaw writes a protocol v1 request to the command's stdin before a supported +third-party skill or plugin install/update stage is committed. One install can +produce more than one policy call. ClawScan evaluates each staged +`sourcePath` and writes one protocol v1 allow/block response to stdout. + +## Resolve the trusted executable + +Install the binary package: + +```sh +npm install -g @openclaw/clawscan +``` + +OpenClaw requires the policy command to be an absolute, non-symlink path. The +package exports a resolver for its native executable: + +```sh +node --input-type=module -e ' + import { pathToFileURL } from "node:url"; + const module = await import(pathToFileURL(process.argv[1]).href); + console.log(module.resolveBundledBinaryPath()); +' "$(npm root -g)/@openclaw/clawscan/lib/resolve-binary.mjs" +``` + +Use the printed path as `command`, and its containing directory in +`trustedDirs`. + +## Configure OpenClaw + +```json5 +{ + security: { + installPolicy: { + enabled: true, + targets: ["skill", "plugin"], + exec: { + source: "exec", + command: "/absolute/path/to/clawscan", + args: ["openclaw-install-policy"], + trustedDirs: ["/absolute/path/to"], + passEnv: ["PATH", "DOCKER_HOST"], + timeoutMs: 1200000, + noOutputTimeoutMs: 1200000, + maxOutputBytes: 1048576, + }, + }, + }, +} +``` + +The default `openclaw-install-policy` profile composes SkillSpector and +`clawscan-static` deterministically and has no judge. ClawScan runs +command-backed scanners in Docker by default. `PATH` lets it locate Docker; +`DOCKER_HOST` is only needed when the local Docker setup uses it. + +For npm plugin installs, OpenClaw calls the policy before mutation with an +`npm-package-metadata.json` file, then calls it again for the resolved package +and installed dependency tree. ClawScan narrowly recognizes the metadata call +from its complete host tuple: plugin/npm request and origin, immutable network +npm source, package content role, file path kind, matching package names, and +the exact metadata filename. It validates that provenance and uses the built-in +static scanner without Docker for this lightweight phase. It does not present +that result as a scan of plugin code. The later package and dependency-tree +calls keep the full profile. Dependency packages are exposed in a dedicated +scan view so normal `node_modules` exclusions cannot hide their code. Local +`plugin-file` requests never match the metadata shortcut. A dependency-tree +phase with no installed runtime dependencies returns an explicit allow/info +response because the package itself was already scanned in the package phase. +For managed npm roots, the dependency view omits only OpenClaw's exact +host-validated `node_modules/openclaw` peer symlink; other links escaping the +staged root fail closed. + +On native Windows, the default profile visibly degrades to +`clawscan-static` with the sandbox disabled because the Linux Docker runtime +cannot consume native Windows staging paths. The allow/block response includes +a warning finding for this reduced coverage. Explicit `--scanner` or +`--sandbox` arguments remain operator-owned and disable this automatic fallback. + +To use an operator-owned profile, add explicit arguments: + +```json5 +args: [ + "openclaw-install-policy", + "--config", + "/absolute/path/to/.clawscan.yml", + "--profile", + "install-policy", +] +``` + +The configured command is the composition point for multiple checks. ClawScan +does not claim an active-scanner singleton and does not replace other policy +engines. Operators can select several scanner adapters in one profile or wrap +several policy checks behind their configured executable and combine their +responses deterministically. + +## Request and response contract + +The command accepts OpenClaw's complete policy payload, including: + +- `targetType`: `skill` or `plugin` +- staged `sourcePath` and `sourcePathKind` +- `source` and `origin` metadata +- request kind, install/update mode, and requested specifier +- target-specific skill or plugin metadata + +ClawScan uses the host-declared target type, so staged plugin files and +dependency trees are scanned as plugins even when they do not contain a +top-level plugin manifest. + +Successful scans return: + +```json +{"protocolVersion":1,"decision":"allow"} +``` + +Warning gate rules return `decision: "allow"` with bounded findings. Blocking +gate rules return `decision: "block"` with critical findings. Invalid requests, +scanner errors, skipped required scanners, empty results, and unknown gate +verdicts return a valid block response with a fail-closed reason. OpenClaw also +fails closed if the executable cannot start, times out, exits nonzero, or emits +malformed output. + +## Scope + +OpenClaw routes supported third-party skill install/update paths and supported +plugin install/update sources through `security.installPolicy`. Skill Workshop +authoring and manual filesystem copies are outside this supply-chain install +scope. diff --git a/docs/profiles.md b/docs/profiles.md index 80d6b71..79fdc3a 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -56,6 +56,7 @@ clawscan profiles -v | Profile | Scanners | Judge | | --- | --- | --- | | `clawhub` | `skillspector`, `clawscan-static` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema | +| `openclaw-install-policy` | `skillspector`, `clawscan-static` | none | ## Build a custom profile with `.clawscan.yml` diff --git a/internal/installpolicy/policy.go b/internal/installpolicy/policy.go new file mode 100644 index 0000000..af07725 --- /dev/null +++ b/internal/installpolicy/policy.go @@ -0,0 +1,422 @@ +package installpolicy + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + "unicode" + + "github.com/openclaw/clawscan/internal/runner" +) + +const ( + maxRequestBytes = 256 * 1024 + maxFindings = 100 + maxTextRunes = 1000 +) + +type Source struct { + Kind string `json:"kind"` + Authority string `json:"authority"` + Mutable bool `json:"mutable"` + Network bool `json:"network"` +} + +type RequestMetadata struct { + Kind string `json:"kind"` + Mode string `json:"mode"` + RequestedSpecifier string `json:"requestedSpecifier,omitempty"` +} + +type PluginMetadata struct { + PluginID string `json:"pluginId"` + ContentType string `json:"contentType"` + PackageName string `json:"packageName,omitempty"` + ManifestID string `json:"manifestId,omitempty"` + Version string `json:"version,omitempty"` + Extensions []string `json:"extensions,omitempty"` +} + +type Request struct { + ProtocolVersion int `json:"protocolVersion"` + OpenClawVersion string `json:"openclawVersion,omitempty"` + TargetType string `json:"targetType"` + TargetName string `json:"targetName"` + SourcePath string `json:"sourcePath"` + SourcePathKind string `json:"sourcePathKind"` + Source *Source `json:"source,omitempty"` + Origin map[string]any `json:"origin"` + Request RequestMetadata `json:"request"` + Skill json.RawMessage `json:"skill,omitempty"` + Plugin *PluginMetadata `json:"plugin,omitempty"` +} + +type Finding struct { + RuleID string `json:"ruleId"` + Severity string `json:"severity"` + Message string `json:"message"` + Evidence string `json:"evidence,omitempty"` +} + +type Response struct { + ProtocolVersion int `json:"protocolVersion"` + Decision string `json:"decision"` + Code string `json:"code,omitempty"` + Reason string `json:"reason,omitempty"` + Findings []Finding `json:"findings,omitempty"` +} + +func AddFinding(response *Response, finding Finding) { + if len(response.Findings) < maxFindings { + response.Findings = append(response.Findings, finding) + return + } + if response.Decision == "allow" && finding.Severity == "warn" { + response.Findings[len(response.Findings)-1] = finding + } +} + +func DecodeRequest(input io.Reader) (Request, error) { + data, err := io.ReadAll(io.LimitReader(input, maxRequestBytes+1)) + if err != nil { + return Request{}, fmt.Errorf("read policy request: %w", err) + } + if len(data) > maxRequestBytes { + return Request{}, fmt.Errorf("policy request exceeds %d bytes", maxRequestBytes) + } + var request Request + if err := json.Unmarshal(data, &request); err != nil { + return Request{}, fmt.Errorf("policy request contains invalid JSON: %w", err) + } + if err := validateRequest(request); err != nil { + return Request{}, err + } + return request, nil +} + +func validateRequest(request Request) error { + if request.ProtocolVersion != 1 { + return errors.New("policy request protocolVersion must be 1") + } + if request.TargetType != "skill" && request.TargetType != "plugin" { + return errors.New(`policy request targetType must be "skill" or "plugin"`) + } + if strings.TrimSpace(request.TargetName) == "" { + return errors.New("policy request targetName must not be empty") + } + if strings.TrimSpace(request.SourcePath) == "" { + return errors.New("policy request sourcePath must not be empty") + } + if request.SourcePathKind != "file" && request.SourcePathKind != "directory" { + return errors.New(`policy request sourcePathKind must be "file" or "directory"`) + } + originType, ok := request.Origin["type"].(string) + if !ok || strings.TrimSpace(originType) == "" { + return errors.New("policy request origin.type must not be empty") + } + if request.Request.Mode != "install" && request.Request.Mode != "update" { + return errors.New(`policy request request.mode must be "install" or "update"`) + } + if request.TargetType == "skill" && request.Request.Kind != "skill-install" { + return errors.New(`skill policy request kind must be "skill-install"`) + } + if request.TargetType == "plugin" && !validPluginRequestKind(request.Request.Kind) { + return errors.New("plugin policy request kind is not supported") + } + if request.TargetType == "plugin" { + if request.Plugin == nil { + return errors.New("plugin policy request plugin metadata must be present") + } + if strings.TrimSpace(request.Plugin.PluginID) == "" { + return errors.New("plugin policy request plugin.pluginId must not be empty") + } + if request.Plugin.PluginID != request.TargetName { + return errors.New("plugin policy request plugin.pluginId must match targetName") + } + switch request.Plugin.ContentType { + case "bundle", "package", "file", "dependency-tree": + default: + return errors.New("plugin policy request plugin.contentType is not supported") + } + originType, _ := request.Origin["type"].(string) + if request.Plugin.ContentType == "dependency-tree" && + (originType != "plugin-dependency-tree" || request.SourcePathKind != "directory") { + return errors.New("dependency-tree policy request metadata is inconsistent") + } + if originType == "plugin-dependency-tree" && + request.Plugin.ContentType != "dependency-tree" { + return errors.New("dependency-tree policy request content role is inconsistent") + } + } + return nil +} + +func validPluginRequestKind(kind string) bool { + switch kind { + case "plugin-dir", "plugin-archive", "plugin-file", "plugin-npm", "plugin-git": + return true + default: + return false + } +} + +func (request Request) IsNPMMetadataPreflight() bool { + if request.TargetType != "plugin" || + request.Request.Kind != "plugin-npm" || + request.SourcePathKind != "file" || + filepath.Base(filepath.Clean(request.SourcePath)) != "npm-package-metadata.json" || + request.Plugin == nil || + request.Plugin.ContentType != "package" || + strings.TrimSpace(request.Plugin.PackageName) == "" || + request.Source == nil || + request.Source.Kind != "npm" || + request.Source.Mutable || + !request.Source.Network || + (request.Source.Authority != "official" && request.Source.Authority != "third-party") { + return false + } + originType, _ := request.Origin["type"].(string) + originPackageName, _ := request.Origin["packageName"].(string) + return originType == "plugin-npm" && originPackageName == request.Plugin.PackageName +} + +func (request Request) IsDependencyTree() bool { + if request.TargetType != "plugin" || + request.SourcePathKind != "directory" || + request.Plugin == nil || + request.Plugin.ContentType != "dependency-tree" { + return false + } + originType, _ := request.Origin["type"].(string) + return originType == "plugin-dependency-tree" +} + +func (request Request) AllowsManagedNPMRootPeerLinks() bool { + return request.IsDependencyTree() && + request.Request.Kind == "plugin-npm" && + request.Source != nil && + request.Source.Kind == "npm" && + !request.Source.Mutable && + request.Source.Network && + (request.Source.Authority == "official" || request.Source.Authority == "third-party") +} + +func ResponseFromArtifact(artifact runner.Artifact) Response { + scannerIDs := make([]string, 0, len(artifact.Scanners)) + for scannerID := range artifact.Scanners { + scannerIDs = append(scannerIDs, scannerID) + } + sort.Strings(scannerIDs) + for _, scannerID := range scannerIDs { + result := artifact.Scanners[scannerID] + if result.Status != "completed" { + return FailureResponse(fmt.Sprintf( + "required scanner %s did not complete (status %s)", + scannerID, + result.Status, + )) + } + if !scannerEvidenceUsable(scannerID, result.Raw) { + return FailureResponse(fmt.Sprintf( + "required scanner %s returned unusable evidence", + scannerID, + )) + } + } + if len(scannerIDs) == 0 { + return FailureResponse("scan produced no scanner results") + } + + if len(artifact.GateRules) > maxFindings { + return FailureResponse("scan returned too many fired gate rules") + } + for _, rule := range artifact.GateRules { + if _, ok := artifact.Scanners[rule.Scanner]; !ok { + return FailureResponse("fired gate rule referenced an unavailable scanner") + } + if rule.Action != "warn" && rule.Action != "block" { + return FailureResponse("scan returned a fired gate rule with an unknown action") + } + } + findings := findingsFromRules(artifact.GateRules) + switch artifact.Gate { + case "pass": + if len(artifact.GateRules) != 0 { + return FailureResponse("pass verdict unexpectedly contained fired gate rules") + } + return Response{ProtocolVersion: 1, Decision: "allow"} + case "warn": + if len(findings) == 0 { + return FailureResponse("warn verdict did not contain a fired warning rule") + } + for _, finding := range findings { + if finding.Severity != "warn" { + return FailureResponse("warn verdict contained a blocking gate rule") + } + } + return Response{ProtocolVersion: 1, Decision: "allow", Findings: findings} + case "block": + hasBlockingFinding := false + for _, finding := range findings { + if finding.Severity == "critical" { + hasBlockingFinding = true + break + } + } + if !hasBlockingFinding { + return FailureResponse("block verdict did not contain a fired blocking rule") + } + return Response{ + ProtocolVersion: 1, + Decision: "block", + Code: "clawscan_gate_blocked", + Reason: "ClawScan gate blocked the staged installation", + Findings: findings, + } + default: + return FailureResponse(fmt.Sprintf("scan returned unknown gate verdict %q", artifact.Gate)) + } +} + +func scannerEvidenceUsable(scannerID string, raw json.RawMessage) bool { + var decoded any + if len(raw) == 0 || json.Unmarshal(raw, &decoded) != nil { + return false + } + record, isRecord := decoded.(map[string]any) + switch scannerID { + case "clawscan-static": + if !isRecord || record["schemaVersion"] != "clawscan-static-v1" { + return false + } + _, ok := record["findings"].([]any) + return ok + case "skillspector": + return isRecord && skillSpectorEvidenceUsable(record) + default: + switch decoded.(type) { + case map[string]any, []any: + return true + default: + return false + } + } +} + +func skillSpectorEvidenceUsable(record map[string]any) bool { + if record["execution_successful"] == false || record["executionSuccessful"] == false { + return false + } + if value, ok := record["error"].(string); ok && strings.TrimSpace(value) != "" { + return false + } + if status, ok := record["status"].(string); ok { + switch strings.ToLower(strings.TrimSpace(status)) { + case "benign", "safe", "clean", "suspicious", "malicious": + return true + } + } + if recommendation, ok := record["recommendation"].(string); ok && + strings.TrimSpace(recommendation) != "" { + return true + } + if _, ok := record["score"].(float64); ok { + return true + } + for _, key := range []string{"risk_assessment", "riskAssessment"} { + if assessment, ok := record[key].(map[string]any); ok { + if recommendation, exists := assessment["recommendation"].(string); exists && + strings.TrimSpace(recommendation) != "" { + return true + } + if _, exists := assessment["score"].(float64); exists { + return true + } + } + } + for _, key := range []string{ + "filtered_findings", + "filteredFindings", + "findings", + "issues", + "vulnerabilities", + } { + if _, ok := record[key].([]any); ok { + return true + } + } + return false +} + +func findingsFromRules(rules []runner.FiredGateRule) []Finding { + findings := make([]Finding, 0, len(rules)) + for _, rule := range rules { + if len(findings) == maxFindings { + break + } + severity := "warn" + if rule.Action == "block" { + severity = "critical" + } + message := fmt.Sprintf("%s fired rule %s", rule.Scanner, rule.Rule) + evidence := "" + switch { + case rule.ExitCode != nil: + evidence = fmt.Sprintf("exitCode=%d", *rule.ExitCode) + case rule.Path != "" && len(rule.Value) > 0: + evidence = fmt.Sprintf("%s=%s", rule.Path, string(rule.Value)) + case rule.Path != "": + evidence = rule.Path + } + findings = append(findings, Finding{ + RuleID: truncateText(rule.Scanner + "." + rule.Rule), + Severity: severity, + Message: truncateText(message), + Evidence: truncateText(evidence), + }) + } + sort.SliceStable(findings, func(i, j int) bool { + return findings[i].RuleID < findings[j].RuleID + }) + return findings +} + +func FailureResponse(reason string) Response { + return Response{ + ProtocolVersion: 1, + Decision: "block", + Code: "clawscan_scan_failed", + Reason: truncateText("ClawScan install policy failed closed: " + reason), + } +} + +func truncateText(value string) string { + cleaned := strings.Map(func(character rune) rune { + if unicode.IsControl(character) { + return ' ' + } + return character + }, value) + runes := []rune(strings.Join(strings.Fields(cleaned), " ")) + if len(runes) <= maxTextRunes { + return string(runes) + } + return string(runes[:maxTextRunes]) + "..." +} + +func WriteResponse(output io.Writer, response Response) error { + response.Code = truncateText(response.Code) + response.Reason = truncateText(response.Reason) + for index := range response.Findings { + response.Findings[index].RuleID = truncateText(response.Findings[index].RuleID) + response.Findings[index].Message = truncateText(response.Findings[index].Message) + response.Findings[index].Evidence = truncateText(response.Findings[index].Evidence) + } + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(false) + return encoder.Encode(response) +} diff --git a/internal/installpolicy/policy_test.go b/internal/installpolicy/policy_test.go new file mode 100644 index 0000000..5c1836d --- /dev/null +++ b/internal/installpolicy/policy_test.go @@ -0,0 +1,368 @@ +package installpolicy + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "unicode" + + "github.com/openclaw/clawscan/internal/runner" +) + +func TestDecodeRequestAcceptsSkillAndPluginPayloads(t *testing.T) { + tests := []struct { + name string + targetType string + request string + wantKind string + }{ + { + name: "skill", + targetType: "skill", + wantKind: "skill-install", + request: `{ + "protocolVersion": 1, + "openclawVersion": "2026.7.2", + "targetType": "skill", + "targetName": "weather", + "sourcePath": "/tmp/staged/weather", + "sourcePathKind": "directory", + "source": {"kind":"clawhub","authority":"third-party","mutable":false,"network":true}, + "origin": {"type":"clawhub","slug":"weather","version":"1.0.0"}, + "request": {"kind":"skill-install","mode":"install","requestedSpecifier":"clawhub:weather@1.0.0"}, + "skill": {"installId":"clawhub"} + }`, + }, + { + name: "plugin", + targetType: "plugin", + wantKind: "plugin-git", + request: `{ + "protocolVersion": 1, + "openclawVersion": "2026.7.2", + "targetType": "plugin", + "targetName": "example", + "sourcePath": "/tmp/staged/example", + "sourcePathKind": "directory", + "source": {"kind":"git","authority":"third-party","mutable":true,"network":true}, + "origin": {"type":"git","url":"https://example.invalid/plugin.git","commit":"abc123"}, + "request": {"kind":"plugin-git","mode":"update","requestedSpecifier":"git:https://example.invalid/plugin.git"}, + "plugin": {"pluginId":"example","contentType":"bundle","manifestId":"example","version":"2.0.0"} + }`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request, err := DecodeRequest(strings.NewReader(test.request)) + if err != nil { + t.Fatal(err) + } + if request.TargetType != test.targetType { + t.Fatalf("targetType = %q, want %q", request.TargetType, test.targetType) + } + if request.Request.Kind != test.wantKind { + t.Fatalf("request.kind = %q, want %q", request.Request.Kind, test.wantKind) + } + if request.Origin["type"] == nil || request.Source == nil { + t.Fatalf("source/origin metadata was not preserved: %#v", request) + } + if request.TargetType == "plugin" && + (request.Plugin == nil || request.Plugin.ContentType != "bundle") { + t.Fatalf("plugin metadata was not preserved: %#v", request.Plugin) + } + }) + } +} + +func TestDecodeRequestRejectsInvalidOrOversizedPayloads(t *testing.T) { + tests := []struct { + name string + payload string + want string + }{ + {name: "malformed", payload: `{`, want: "invalid JSON"}, + {name: "protocol", payload: `{"protocolVersion":2}`, want: "protocolVersion must be 1"}, + { + name: "target", + payload: `{ + "protocolVersion":1, + "targetType":"channel", + "targetName":"demo", + "sourcePath":"/tmp/demo", + "sourcePathKind":"directory", + "origin":{"type":"test"}, + "request":{"kind":"skill-install","mode":"install"} + }`, + want: `targetType must be "skill" or "plugin"`, + }, + { + name: "oversized", + payload: `{"protocolVersion":1,"padding":"` + strings.Repeat("x", maxRequestBytes) + `"}`, + want: "exceeds", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := DecodeRequest(strings.NewReader(test.payload)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { + exitCode := 3 + tests := []struct { + name string + artifact runner.Artifact + decision string + code string + findings int + }{ + { + name: "pass", + artifact: runner.Artifact{ + Gate: "pass", + Scanners: map[string]runner.ScannerResult{"static": { + Status: "completed", + Raw: json.RawMessage(`{"findings":[]}`), + }}, + }, + decision: "allow", + }, + { + name: "warn", + artifact: runner.Artifact{ + Gate: "warn", + Scanners: map[string]runner.ScannerResult{"static": { + Status: "completed", + Raw: json.RawMessage(`{"findings":[]}`), + }}, + GateRules: []runner.FiredGateRule{{ + Scanner: "static", + Rule: "finding", + Path: "findings[]", + Action: "warn", + }}, + }, + decision: "allow", + findings: 1, + }, + { + name: "block", + artifact: runner.Artifact{ + Gate: "block", + Scanners: map[string]runner.ScannerResult{"static": { + Status: "completed", + Raw: json.RawMessage(`{"findings":[]}`), + }}, + GateRules: []runner.FiredGateRule{{ + Scanner: "static", + Rule: "exit-code", + ExitCode: &exitCode, + Action: "block", + }}, + }, + decision: "block", + code: "clawscan_gate_blocked", + findings: 1, + }, + { + name: "unusable completed evidence", + artifact: runner.Artifact{ + Gate: "pass", + Scanners: map[string]runner.ScannerResult{ + "skillspector": {Status: "completed", Raw: json.RawMessage(`{}`)}, + }, + }, + decision: "block", + code: "clawscan_scan_failed", + }, + { + name: "scanner failure", + artifact: runner.Artifact{ + Gate: "pass", + Scanners: map[string]runner.ScannerResult{"static": {Status: "failed", Error: "boom"}}, + }, + decision: "block", + code: "clawscan_scan_failed", + }, + { + name: "scanner skipped", + artifact: runner.Artifact{ + Gate: "pass", + Scanners: map[string]runner.ScannerResult{"static": {Status: "skipped"}}, + }, + decision: "block", + code: "clawscan_scan_failed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := ResponseFromArtifact(test.artifact) + if response.Decision != test.decision || response.Code != test.code { + t.Fatalf("response = %#v", response) + } + if len(response.Findings) != test.findings { + t.Fatalf("findings = %#v, want %d", response.Findings, test.findings) + } + }) + } +} + +func TestFailureResponseAndWriteResponseUsePolicyProtocol(t *testing.T) { + response := FailureResponse("scanner exploded") + var output bytes.Buffer + if err := WriteResponse(&output, response); err != nil { + t.Fatal(err) + } + + var decoded map[string]any + if err := json.Unmarshal(output.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if decoded["protocolVersion"] != float64(1) || + decoded["decision"] != "block" || + decoded["code"] != "clawscan_scan_failed" { + t.Fatalf("response = %#v", decoded) + } +} + +func TestWriteResponseSanitizesControlCharactersInAllDiagnosticText(t *testing.T) { + response := Response{ + ProtocolVersion: 1, + Decision: "block", + Code: "scan\x1b[31m_failed", + Reason: "first line\nforged line\tend", + Findings: []Finding{{ + RuleID: "scanner.\x00rule", + Severity: "critical", + Message: "message\r\nnext", + Evidence: "path=\x1b[2J/tmp/demo", + }}, + } + var output bytes.Buffer + if err := WriteResponse(&output, response); err != nil { + t.Fatal(err) + } + var decoded Response + if err := json.Unmarshal(output.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{ + "code": decoded.Code, + "reason": decoded.Reason, + "ruleId": decoded.Findings[0].RuleID, + "message": decoded.Findings[0].Message, + "evidence": decoded.Findings[0].Evidence, + } { + for _, character := range value { + if unicode.IsControl(character) { + t.Fatalf("%s retained control character in %q", name, value) + } + } + } + if decoded.Reason != "first line forged line end" { + t.Fatalf("reason = %q", decoded.Reason) + } +} + +func TestResponseFromArtifactBoundsUntrustedFindingOutput(t *testing.T) { + rules := make([]runner.FiredGateRule, maxFindings) + for index := range rules { + rules[index] = runner.FiredGateRule{ + Scanner: "static", + Rule: strings.Repeat("r", maxTextRunes+20), + Path: strings.Repeat("p", maxTextRunes+20), + Action: "warn", + } + } + response := ResponseFromArtifact(runner.Artifact{ + Gate: "warn", + GateRules: rules, + Scanners: map[string]runner.ScannerResult{"static": { + Status: "completed", + Raw: json.RawMessage(`{"findings":[]}`), + }}, + }) + if len(response.Findings) != maxFindings { + t.Fatalf("findings = %d, want %d", len(response.Findings), maxFindings) + } + if len([]rune(response.Findings[0].RuleID)) > maxTextRunes+3 || + len([]rune(response.Findings[0].Evidence)) > maxTextRunes+3 { + t.Fatalf("finding was not bounded: %#v", response.Findings[0]) + } +} + +func TestAddFindingPreservesProtocolFindingBound(t *testing.T) { + response := Response{ + ProtocolVersion: 1, + Decision: "allow", + Findings: make([]Finding, maxFindings), + } + AddFinding(&response, Finding{RuleID: "info", Severity: "info"}) + if len(response.Findings) != maxFindings { + t.Fatalf("findings = %d", len(response.Findings)) + } + AddFinding(&response, Finding{RuleID: "fallback", Severity: "warn"}) + if response.Findings[maxFindings-1].RuleID != "fallback" { + t.Fatalf("visible warning was not retained: %#v", response.Findings[maxFindings-1]) + } +} + +func TestResponseFromArtifactValidatesBuiltInEvidenceSchemas(t *testing.T) { + tests := []struct { + name string + scannerID string + raw string + decision string + }{ + { + name: "static valid", + scannerID: "clawscan-static", + raw: `{"schemaVersion":"clawscan-static-v1","findings":[]}`, + decision: "allow", + }, + { + name: "static missing schema", + scannerID: "clawscan-static", + raw: `{"findings":[]}`, + decision: "block", + }, + { + name: "skillspector valid clean", + scannerID: "skillspector", + raw: `{"status":"clean","findings":[]}`, + decision: "allow", + }, + { + name: "skillspector error shaped", + scannerID: "skillspector", + raw: `{"error":"scan failed"}`, + decision: "block", + }, + { + name: "skillspector empty recommendation", + scannerID: "skillspector", + raw: `{"recommendation":""}`, + decision: "block", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := ResponseFromArtifact(runner.Artifact{ + Gate: "pass", + Scanners: map[string]runner.ScannerResult{ + test.scannerID: {Status: "completed", Raw: json.RawMessage(test.raw)}, + }, + }) + if response.Decision != test.decision { + t.Fatalf("response = %#v", response) + } + }) + } +} diff --git a/internal/installpolicy/stages.go b/internal/installpolicy/stages.go new file mode 100644 index 0000000..bc1248c --- /dev/null +++ b/internal/installpolicy/stages.go @@ -0,0 +1,373 @@ +package installpolicy + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + maxDependencyPackages = 10_000 + maxDependencyEntries = 100_000 + maxDependencyFileBytes = 64 * 1024 * 1024 + maxDependencyTotalBytes = 512 * 1024 * 1024 +) + +type dependencyCopyBudget struct { + entries int + totalBytes int64 +} + +type npmPreflightMetadata struct { + PackageName string `json:"packageName"` + RequestedSpecifier string `json:"requestedSpecifier"` + Resolution struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"resolution"` +} + +func ValidateNPMMetadataPreflight(request Request) error { + if !request.IsNPMMetadataPreflight() { + return errors.New("request is not an OpenClaw npm metadata preflight") + } + info, err := os.Lstat(request.SourcePath) + if err != nil { + return fmt.Errorf("inspect npm preflight metadata: %w", err) + } + if !info.Mode().IsRegular() { + return errors.New("npm preflight metadata must be a regular file") + } + if info.Size() > maxRequestBytes { + return fmt.Errorf("npm preflight metadata exceeds %d bytes", maxRequestBytes) + } + file, err := os.Open(request.SourcePath) + if err != nil { + return fmt.Errorf("open npm preflight metadata: %w", err) + } + defer file.Close() + + var metadata npmPreflightMetadata + decoder := json.NewDecoder(io.LimitReader(file, maxRequestBytes+1)) + if err := decoder.Decode(&metadata); err != nil { + return fmt.Errorf("parse npm preflight metadata: %w", err) + } + if err := rejectTrailingJSON(decoder); err != nil { + return err + } + if metadata.PackageName != request.Plugin.PackageName { + return errors.New("npm preflight metadata packageName does not match policy metadata") + } + if strings.TrimSpace(request.Request.RequestedSpecifier) == "" || + metadata.RequestedSpecifier != request.Request.RequestedSpecifier { + return errors.New("npm preflight metadata requestedSpecifier does not match policy metadata") + } + if metadata.Resolution.Name != metadata.PackageName { + return errors.New("npm preflight resolution name does not match packageName") + } + if strings.TrimSpace(metadata.Resolution.Version) == "" { + return errors.New("npm preflight resolution version must not be empty") + } + return nil +} + +func rejectTrailingJSON(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("npm preflight metadata contains multiple JSON values") + } + return fmt.Errorf("parse npm preflight metadata trailing data: %w", err) + } + return nil +} + +// PrepareDependencyTreeScanTarget exposes each installed npm package in one +// temporary scan root without a node_modules path segment. ClawScan's normal +// source scanners intentionally skip node_modules for ordinary repository +// scans, so the install-policy adapter uses this view only for OpenClaw's +// explicit dependency-tree phase. +func PrepareDependencyTreeScanTarget( + sourcePath string, + allowManagedNPMRootPeerLinks bool, +) (string, func(), bool, error) { + root, err := filepath.Abs(sourcePath) + if err != nil { + return "", nil, false, fmt.Errorf("resolve dependency-tree root: %w", err) + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", nil, false, fmt.Errorf("resolve dependency-tree root symlinks: %w", err) + } + info, err := os.Stat(root) + if err != nil { + return "", nil, false, fmt.Errorf("inspect dependency-tree root: %w", err) + } + if !info.IsDir() { + return "", nil, false, errors.New("dependency-tree sourcePath must be a directory") + } + + packageDirs, err := collectDependencyPackageDirs(root, allowManagedNPMRootPeerLinks) + if err != nil { + return "", nil, false, err + } + if len(packageDirs) == 0 { + return "", func() {}, true, nil + } + + tempRoot, err := os.MkdirTemp("", "clawscan-openclaw-dependencies-*") + if err != nil { + return "", nil, false, fmt.Errorf("create dependency scan root: %w", err) + } + cleanup := func() { + _ = os.RemoveAll(tempRoot) + } + scanRoot := filepath.Join(tempRoot, "packages") + budget := dependencyCopyBudget{} + for index, packageDir := range packageDirs { + destination := filepath.Join(scanRoot, fmt.Sprintf("%05d", index+1)) + if err := copyDependencyPackage(packageDir, destination, &budget); err != nil { + cleanup() + return "", nil, false, err + } + } + resolvedScanRoot, err := filepath.EvalSymlinks(scanRoot) + if err != nil { + cleanup() + return "", nil, false, fmt.Errorf("resolve dependency scan root: %w", err) + } + return resolvedScanRoot, cleanup, false, nil +} + +func collectDependencyPackageDirs( + root string, + allowManagedNPMRootPeerLinks bool, +) ([]string, error) { + queue := []string{root} + visitedParents := map[string]bool{} + packageSet := map[string]bool{} + for len(queue) > 0 { + parent := queue[0] + queue = queue[1:] + if visitedParents[parent] { + continue + } + visitedParents[parent] = true + nodeModules := filepath.Join(parent, "node_modules") + info, err := os.Lstat(nodeModules) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("inspect dependency directory %s: %w", nodeModules, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("dependency directory is not a directory: %s", nodeModules) + } + entries, err := os.ReadDir(nodeModules) + if err != nil { + return nil, fmt.Errorf("read dependency directory %s: %w", nodeModules, err) + } + for _, entry := range entries { + if entry.Name() == ".bin" { + continue + } + if strings.HasPrefix(entry.Name(), "@") { + scopePath := filepath.Join(nodeModules, entry.Name()) + scopeEntries, err := os.ReadDir(scopePath) + if err != nil { + return nil, fmt.Errorf("read dependency scope %s: %w", scopePath, err) + } + for _, scopeEntry := range scopeEntries { + if err := addDependencyPackage( + root, + parent, + filepath.Join(scopePath, scopeEntry.Name()), + allowManagedNPMRootPeerLinks, + packageSet, + &queue, + ); err != nil { + return nil, err + } + } + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if err := addDependencyPackage( + root, + parent, + filepath.Join(nodeModules, entry.Name()), + allowManagedNPMRootPeerLinks, + packageSet, + &queue, + ); err != nil { + return nil, err + } + } + } + if len(packageSet) > maxDependencyPackages { + return nil, fmt.Errorf("dependency-tree contains more than %d packages", maxDependencyPackages) + } + packageDirs := make([]string, 0, len(packageSet)) + for packageDir := range packageSet { + packageDirs = append(packageDirs, packageDir) + } + sort.Strings(packageDirs) + return packageDirs, nil +} + +func addDependencyPackage( + root string, + parentPackage string, + candidate string, + allowManagedNPMRootPeerLinks bool, + packageSet map[string]bool, + queue *[]string, +) error { + candidateInfo, err := os.Lstat(candidate) + if err != nil { + return fmt.Errorf("inspect installed dependency %s: %w", candidate, err) + } + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return fmt.Errorf("resolve installed dependency %s: %w", candidate, err) + } + if !pathWithin(root, resolved) { + if candidateInfo.Mode()&os.ModeSymlink != 0 && + filepath.Base(candidate) == "openclaw" && + (parentPackage == root || allowManagedNPMRootPeerLinks) { + // OpenClaw validates this exact peer link against its trusted host + // package before invoking the external policy. The host runtime is + // not third-party dependency code, so it is deliberately omitted. + return nil + } + return fmt.Errorf("installed dependency escapes dependency-tree root: %s", candidate) + } + info, err := os.Stat(resolved) + if err != nil { + return fmt.Errorf("inspect installed dependency %s: %w", candidate, err) + } + if !info.IsDir() { + return fmt.Errorf("installed dependency is not a directory: %s", candidate) + } + manifestInfo, err := os.Lstat(filepath.Join(resolved, "package.json")) + if err != nil || !manifestInfo.Mode().IsRegular() { + return fmt.Errorf("installed dependency has no regular package.json: %s", candidate) + } + if packageSet[resolved] { + return nil + } + packageSet[resolved] = true + if len(packageSet) > maxDependencyPackages { + return fmt.Errorf("dependency-tree contains more than %d packages", maxDependencyPackages) + } + *queue = append(*queue, resolved) + return nil +} + +func copyDependencyPackage( + source string, + destination string, + budget *dependencyCopyBudget, +) error { + return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("read installed dependency %s: %w", path, walkErr) + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + if relative == "." { + return os.MkdirAll(destination, 0o755) + } + budget.entries++ + if budget.entries > maxDependencyEntries { + return fmt.Errorf( + "dependency-tree scan view exceeds %d filesystem entries", + maxDependencyEntries, + ) + } + if entry.IsDir() { + if entry.Name() == "node_modules" || entry.Name() == ".git" { + return filepath.SkipDir + } + return os.MkdirAll(filepath.Join(destination, relative), 0o755) + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("installed dependency contains a special file: %s", path) + } + if info.Size() > maxDependencyFileBytes { + return fmt.Errorf( + "dependency file exceeds %d bytes: %s", + maxDependencyFileBytes, + path, + ) + } + if budget.totalBytes > maxDependencyTotalBytes-info.Size() { + return fmt.Errorf( + "dependency-tree scan view exceeds %d total bytes", + maxDependencyTotalBytes, + ) + } + budget.totalBytes += info.Size() + return copyRegularFile(path, filepath.Join(destination, relative), info.Size()) + }) +} + +func copyRegularFile(source string, destination string, expectedBytes int64) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + copiedBytes, copyErr := io.CopyN(output, input, expectedBytes) + if errors.Is(copyErr, io.EOF) { + copyErr = fmt.Errorf("dependency file changed while copying: %s", source) + } + if copyErr == nil && copiedBytes != expectedBytes { + copyErr = fmt.Errorf("dependency file changed while copying: %s", source) + } + if copyErr == nil { + var trailing [1]byte + if trailingBytes, readErr := input.Read(trailing[:]); readErr != nil && !errors.Is(readErr, io.EOF) { + copyErr = readErr + } else if trailingBytes != 0 { + copyErr = fmt.Errorf("dependency file changed while copying: %s", source) + } + } + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func pathWithin(root string, candidate string) bool { + relative, err := filepath.Rel(root, candidate) + return err == nil && + relative != ".." && + !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/internal/installpolicy/stages_test.go b/internal/installpolicy/stages_test.go new file mode 100644 index 0000000..8fe941e --- /dev/null +++ b/internal/installpolicy/stages_test.go @@ -0,0 +1,327 @@ +package installpolicy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNPMMetadataPreflightMatchesOnlyFullOpenClawTuple(t *testing.T) { + dir := t.TempDir() + metadataPath := filepath.Join(dir, "npm-package-metadata.json") + writeStageTestFile(t, metadataPath, `{ + "packageName":"@acme/demo", + "requestedSpecifier":"@acme/demo@1.2.3", + "resolution":{"name":"@acme/demo","version":"1.2.3"} + }`) + request := npmMetadataPreflightRequest(metadataPath) + if !request.IsNPMMetadataPreflight() { + t.Fatal("expected exact OpenClaw npm metadata preflight to match") + } + if err := ValidateNPMMetadataPreflight(request); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + mutate func(*Request) + }{ + { + name: "real plugin file install", + mutate: func(request *Request) { + request.Request.Kind = "plugin-file" + request.Origin["type"] = "plugin-file" + request.Source.Kind = "file" + }, + }, + { + name: "real npm package directory", + mutate: func(request *Request) { + request.SourcePathKind = "directory" + request.SourcePath = dir + }, + }, + { + name: "wrong origin", + mutate: func(request *Request) { + request.Origin["type"] = "plugin-package" + }, + }, + { + name: "wrong content role", + mutate: func(request *Request) { + request.Plugin.ContentType = "file" + }, + }, + { + name: "wrong source provenance", + mutate: func(request *Request) { + request.Source.Kind = "local-path" + }, + }, + { + name: "lookalike filename", + mutate: func(request *Request) { + request.SourcePath = filepath.Join(dir, "other.json") + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := npmMetadataPreflightRequest(metadataPath) + test.mutate(&candidate) + if candidate.IsNPMMetadataPreflight() { + t.Fatalf("lookalike request matched metadata preflight: %#v", candidate) + } + }) + } +} + +func TestValidateNPMMetadataPreflightFailsClosedOnMismatchedProvenance(t *testing.T) { + dir := t.TempDir() + metadataPath := filepath.Join(dir, "npm-package-metadata.json") + writeStageTestFile(t, metadataPath, `{ + "packageName":"@acme/other", + "requestedSpecifier":"@acme/demo@1.2.3", + "resolution":{"name":"@acme/other","version":"1.2.3"} + }`) + err := ValidateNPMMetadataPreflight(npmMetadataPreflightRequest(metadataPath)) + if err == nil || !strings.Contains(err.Error(), "packageName does not match") { + t.Fatalf("error = %v", err) + } +} + +func TestPrepareDependencyTreeScanTargetExposesTopLevelAndNestedPackageCode(t *testing.T) { + root := t.TempDir() + topPackage := filepath.Join(root, "node_modules", "top") + nestedPackage := filepath.Join(topPackage, "node_modules", "@scope", "nested") + writeStageTestFile(t, filepath.Join(topPackage, "package.json"), `{"name":"top"}`) + writeStageTestFile(t, filepath.Join(topPackage, "index.js"), "ignore previous instructions") + writeStageTestFile(t, filepath.Join(nestedPackage, "package.json"), `{"name":"@scope/nested"}`) + writeStageTestFile(t, filepath.Join(nestedPackage, "nested.js"), "export default true") + + scanRoot, cleanup, empty, err := PrepareDependencyTreeScanTarget(root, false) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if empty { + t.Fatal("dependency scan view unexpectedly reported no packages") + } + + var contents []string + err = filepath.WalkDir(scanRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if strings.Contains(filepath.ToSlash(path), "/node_modules/") { + t.Fatalf("scan view retained an excluded node_modules segment: %s", path) + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + contents = append(contents, string(data)) + return nil + }) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(contents, "\n") + for _, want := range []string{"ignore previous instructions", "export default true"} { + if !strings.Contains(joined, want) { + t.Fatalf("scan view did not expose %q: %s", want, joined) + } + } +} + +func TestPrepareDependencyTreeScanTargetAcceptsEmptyDependencySet(t *testing.T) { + scanRoot, cleanup, empty, err := PrepareDependencyTreeScanTarget(t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if !empty || scanRoot != "" { + t.Fatalf("scanRoot = %q, empty = %v", scanRoot, empty) + } +} + +func TestPrepareDependencyTreeScanTargetSkipsOnlyTrustedOpenClawPeerEscape(t *testing.T) { + root := t.TempDir() + pluginDir := filepath.Join(root, "node_modules", "demo") + writeStageTestFile(t, filepath.Join(pluginDir, "package.json"), `{"name":"demo"}`) + writeStageTestFile(t, filepath.Join(pluginDir, "index.js"), "export default true") + + hostRoot := t.TempDir() + writeStageTestFile(t, filepath.Join(hostRoot, "package.json"), `{"name":"openclaw"}`) + peerLink := filepath.Join(pluginDir, "node_modules", "openclaw") + if err := os.MkdirAll(filepath.Dir(peerLink), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(hostRoot, peerLink); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + + if _, _, _, err := PrepareDependencyTreeScanTarget(root, false); err == nil || + !strings.Contains(err.Error(), "escapes dependency-tree root") { + t.Fatalf("untrusted peer escape error = %v", err) + } + scanRoot, cleanup, empty, err := PrepareDependencyTreeScanTarget(root, true) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if empty { + t.Fatal("plugin package should remain in the dependency scan view") + } + if data, err := os.ReadFile(filepath.Join(scanRoot, "00001", "index.js")); err != nil || + string(data) != "export default true" { + t.Fatalf("plugin code missing from scan view: data=%q err=%v", data, err) + } + + evilRoot := t.TempDir() + evilLink := filepath.Join(evilRoot, "node_modules", "evil") + if err := os.MkdirAll(filepath.Dir(evilLink), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(hostRoot, evilLink); err != nil { + t.Fatal(err) + } + if _, _, _, err := PrepareDependencyTreeScanTarget(evilRoot, true); err == nil || + !strings.Contains(err.Error(), "escapes dependency-tree root") { + t.Fatalf("arbitrary peer escape error = %v", err) + } +} + +func TestCopyDependencyPackageEnforcesEntryAndByteBudgets(t *testing.T) { + source := t.TempDir() + writeStageTestFile(t, filepath.Join(source, "package.json"), `{"name":"demo"}`) + + entryBudget := dependencyCopyBudget{entries: maxDependencyEntries} + err := copyDependencyPackage(source, filepath.Join(t.TempDir(), "entries"), &entryBudget) + if err == nil || !strings.Contains(err.Error(), "filesystem entries") { + t.Fatalf("entry budget error = %v", err) + } + + byteBudget := dependencyCopyBudget{totalBytes: maxDependencyTotalBytes} + err = copyDependencyPackage(source, filepath.Join(t.TempDir(), "bytes"), &byteBudget) + if err == nil || !strings.Contains(err.Error(), "total bytes") { + t.Fatalf("byte budget error = %v", err) + } + + largeSource := t.TempDir() + largePath := filepath.Join(largeSource, "large.bin") + file, err := os.Create(largePath) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxDependencyFileBytes + 1); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + err = copyDependencyPackage( + largeSource, + filepath.Join(t.TempDir(), "large"), + &dependencyCopyBudget{}, + ) + if err == nil || !strings.Contains(err.Error(), "dependency file exceeds") { + t.Fatalf("file budget error = %v", err) + } +} + +func TestDependencyTreeMatchesOnlyExplicitOpenClawStage(t *testing.T) { + request := Request{ + TargetType: "plugin", + TargetName: "demo", + SourcePath: "/tmp/npm-root", + SourcePathKind: "directory", + Origin: map[string]any{"type": "plugin-dependency-tree"}, + Request: RequestMetadata{Kind: "plugin-npm", Mode: "install"}, + Plugin: &PluginMetadata{ + PluginID: "demo", + ContentType: "dependency-tree", + }, + } + if !request.IsDependencyTree() { + t.Fatal("expected dependency-tree stage") + } + request.Origin["type"] = "plugin-npm" + if request.IsDependencyTree() { + t.Fatal("package stage must not match dependency-tree handling") + } +} + +func TestAllowsManagedNPMRootPeerLinksRequiresExactNPMProvenance(t *testing.T) { + request := Request{ + TargetType: "plugin", + TargetName: "demo", + SourcePath: "/tmp/npm-root", + SourcePathKind: "directory", + Source: &Source{ + Kind: "npm", + Authority: "third-party", + Mutable: false, + Network: true, + }, + Origin: map[string]any{"type": "plugin-dependency-tree"}, + Request: RequestMetadata{Kind: "plugin-npm", Mode: "install"}, + Plugin: &PluginMetadata{ + PluginID: "demo", + ContentType: "dependency-tree", + }, + } + if !request.AllowsManagedNPMRootPeerLinks() { + t.Fatal("expected managed npm dependency stage to allow the host peer shape") + } + request.Request.Kind = "plugin-git" + if request.AllowsManagedNPMRootPeerLinks() { + t.Fatal("git dependency stage must not allow managed npm root peer links") + } +} + +func npmMetadataPreflightRequest(path string) Request { + return Request{ + ProtocolVersion: 1, + TargetType: "plugin", + TargetName: "demo", + SourcePath: path, + SourcePathKind: "file", + Source: &Source{ + Kind: "npm", + Authority: "third-party", + Mutable: false, + Network: true, + }, + Origin: map[string]any{ + "type": "plugin-npm", + "packageName": "@acme/demo", + }, + Request: RequestMetadata{ + Kind: "plugin-npm", + Mode: "install", + RequestedSpecifier: "@acme/demo@1.2.3", + }, + Plugin: &PluginMetadata{ + PluginID: "demo", + ContentType: "package", + PackageName: "@acme/demo", + }, + } +} + +func writeStageTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/npm/clawscan-plugin/profiles/clawhub.yml b/internal/profiles/openclaw-install-policy/clawscan.yml similarity index 92% rename from npm/clawscan-plugin/profiles/clawhub.yml rename to internal/profiles/openclaw-install-policy/clawscan.yml index 45ff933..90425e8 100644 --- a/npm/clawscan-plugin/profiles/clawhub.yml +++ b/internal/profiles/openclaw-install-policy/clawscan.yml @@ -1,7 +1,7 @@ version: 1 profiles: - clawhub: + openclaw-install-policy: scanners: - id: skillspector gate: @@ -44,13 +44,8 @@ profiles: action: warn - id: clawscan-static gate: - rules: &static-gate-rules + rules: - id: any-finding path: findings[] exists: true action: warn - clawhub-static: - scanners: - - id: clawscan-static - gate: - rules: *static-gate-rules diff --git a/internal/profiles/registry_test.go b/internal/profiles/registry_test.go index bcfc0a6..ca3ffb5 100644 --- a/internal/profiles/registry_test.go +++ b/internal/profiles/registry_test.go @@ -83,7 +83,7 @@ func TestInspectProfilesReturnsBuiltIns(t *testing.T) { if err != nil { t.Fatal(err) } - if got := strings.Join(catalog.IDs(), ","); got != "clawhub,clawhub-aig" { + if got := strings.Join(catalog.IDs(), ","); got != "clawhub,clawhub-aig,openclaw-install-policy" { t.Fatalf("profile ids = %q", got) } diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index dc5ff66..764fd6b 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -17,11 +17,12 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed clawhub/clawscan.yml clawhub/prompt.md clawhub/output.schema.json +//go:embed clawhub/clawscan.yml clawhub/prompt.md clawhub/output.schema.json openclaw-install-policy/clawscan.yml var builtinFiles embed.FS var builtinProfileConfigPaths = []string{ "clawhub/clawscan.yml", + "openclaw-install-policy/clawscan.yml", } var jsonIntegerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index bd66781..295009f 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -223,7 +223,7 @@ profiles: if err == nil { t.Fatal("expected unknown profile error") } - want := "Unknown profile: custom (available: clawhub, clawhub-aig)" + want := "Unknown profile: custom (available: clawhub, clawhub-aig, openclaw-install-policy)" if err.Error() != want { t.Fatalf("error = %q, want %q", err, want) } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index a0185ff..51cae9a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -28,6 +28,7 @@ import ( type Options struct { Target string + TargetKind string Profile string ConfigSource string DiscoverConfig bool @@ -458,7 +459,7 @@ func Run(opts Options, ctx RunContext) (Artifact, error) { env = EnvMap(os.Environ()) } applyRuntimeEnvDefaults(opts, env) - target, err := resolveTarget(opts.Target) + target, err := resolveTargetForOptions(opts) if err != nil { return Artifact{}, err } diff --git a/internal/runner/target.go b/internal/runner/target.go index d800d51..97ce2fb 100644 --- a/internal/runner/target.go +++ b/internal/runner/target.go @@ -2,6 +2,7 @@ package runner import ( "encoding/json" + "errors" "fmt" "io" "net/url" @@ -78,6 +79,32 @@ type resolvedTarget struct { id string } +func resolveTargetForOptions(opts Options) (resolvedTarget, error) { + if opts.TargetKind == "" { + return resolveTarget(opts.Target) + } + if opts.TargetKind != targetKindSkill && opts.TargetKind != targetKindPlugin { + return resolvedTarget{}, fmt.Errorf("unsupported target kind override: %s", opts.TargetKind) + } + if isURLTarget(opts.Target) { + return resolvedTarget{}, errors.New("target kind override requires a local path") + } + resolved, err := filepath.Abs(opts.Target) + if err != nil { + return resolvedTarget{}, err + } + if info, err := os.Lstat(resolved); err == nil && info.Mode()&os.ModeSymlink != 0 { + if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil { + resolved = evaluated + } + } + return resolvedTarget{ + kind: opts.TargetKind, + input: opts.Target, + resolvedPath: resolved, + }, nil +} + func resolveTarget(input string) (resolvedTarget, error) { if isURLTarget(input) { return resolvedTarget{kind: targetKindURL, input: input, resolvedPath: input}, nil diff --git a/internal/runner/target_test.go b/internal/runner/target_test.go index 8792785..6ec8027 100644 --- a/internal/runner/target_test.go +++ b/internal/runner/target_test.go @@ -449,6 +449,28 @@ func TestRunStaticScannerCompletesForPluginTarget(t *testing.T) { } } +func TestResolveTargetUsesTrustedTargetKindOverride(t *testing.T) { + dir := t.TempDir() + pluginFile := filepath.Join(dir, "plugin.js") + if err := os.WriteFile(pluginFile, []byte("export default {};\n"), 0o600); err != nil { + t.Fatal(err) + } + + target, err := resolveTargetForOptions(Options{ + Target: pluginFile, + TargetKind: "plugin", + }) + if err != nil { + t.Fatal(err) + } + if target.kind != targetKindPlugin { + t.Fatalf("kind = %q, want plugin", target.kind) + } + if target.resolvedPath != pluginFile { + t.Fatalf("resolvedPath = %q, want %q", target.resolvedPath, pluginFile) + } +} + func TestRunFailsForInvalidPluginManifest(t *testing.T) { dir := filepath.Join(t.TempDir(), "bad-plugin") if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/npm/clawscan-plugin/README.md b/npm/clawscan-plugin/README.md deleted file mode 100644 index cf3cd17..0000000 --- a/npm/clawscan-plugin/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# ClawScan Install Gate for OpenClaw - -`@openclaw/clawscan-plugin` registers OpenClaw's `before_install` hook and -fails closed when ClawScan cannot produce a trustworthy gate artifact. - -This package requires OpenClaw's -[cold install-provider contract](https://github.com/openclaw/openclaw/pull/115197), -which discovers explicitly trusted `before_install` providers before both CLI -and Gateway install/update operations. Earlier prerelease builds that only run -hooks already loaded in the current process are not supported. - -The host contract has not shipped in an OpenClaw release. This package is -therefore a private preview and is intentionally excluded from npm and ClawHub -publication. Once a supporting host release exists, the package metadata must -be updated to name that release as the minimum supported version before -publication is enabled. - -After that release boundary is defined and this package is published, install -the plugin through OpenClaw: - -```sh -openclaw plugins install @openclaw/clawscan-plugin -``` - -That future operator action explicitly trusts and enables this config-free plugin by -writing `plugins.entries.clawscan.enabled=true` and adding `clawscan` to -`plugins.allow` when the allowlist is configured. If the package is placed by -another mechanism, run `openclaw plugins enable clawscan` and ensure the -allowlist includes `clawscan` before relying on the install hook. - -By default, every candidate skill or plugin is scanned with SkillSpector -(`CLAWSCAN_SKILLSPECTOR_LLM=0`) and `clawscan-static` inside ClawScan's Docker -sandbox. This no-LLM mode does not send source files to a model provider, but -SkillSpector still sends dependency names to [OSV.dev](https://osv.dev/) for -CVE lookups. - -If Docker mode is unavailable on the host, including on native Windows, the -plugin visibly reports that the gate is degraded and runs only -`clawscan-static` with the sandbox disabled. This fallback is a small static -tripwire, not equivalent protection. - -The plugin accepts only an explicit `configPath` and `profile`. Relative config -paths resolve from the plugin directory; the untrusted candidate directory is -never searched for ClawScan configuration. - -The gate cannot scan its own first installation because its hook is not active -yet. Enable it immediately after installation. Once enabled, it scans -subsequent updates, including updates to itself. diff --git a/npm/clawscan-plugin/index.ts b/npm/clawscan-plugin/index.ts deleted file mode 100644 index dd3651c..0000000 --- a/npm/clawscan-plugin/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { resolveBundledBinaryPath } from "@openclaw/clawscan/resolve-binary"; -import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { registerInstallGate } from "./src/register.ts"; - -export default definePluginEntry({ - id: "clawscan", - name: "ClawScan Install Gate", - description: "Scans candidate skills and plugins before OpenClaw installs or updates them.", - register: (api: OpenClawPluginApi) => registerInstallGate(api, resolveBundledBinaryPath), -}); diff --git a/npm/clawscan-plugin/openclaw.plugin.json b/npm/clawscan-plugin/openclaw.plugin.json deleted file mode 100644 index 878f6e4..0000000 --- a/npm/clawscan-plugin/openclaw.plugin.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "id": "clawscan", - "activation": { - "onStartup": false, - "onHooks": ["before_install"], - "onCapabilities": ["hook"] - }, - "name": "ClawScan Install Gate", - "description": "Scans candidate skills and plugins before OpenClaw installs or updates them.", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "configPath": { - "type": "string", - "minLength": 1, - "description": "Explicit ClawScan config path. Relative paths resolve inside this plugin." - }, - "profile": { - "type": "string", - "minLength": 1, - "description": "Profile selected from the explicit config path." - } - } - } -} diff --git a/npm/clawscan-plugin/package.json b/npm/clawscan-plugin/package.json deleted file mode 100644 index 6e20845..0000000 --- a/npm/clawscan-plugin/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@openclaw/clawscan-plugin", - "version": "0.0.0-dev", - "private": true, - "description": "Fail-closed ClawScan install gate for OpenClaw skills and plugins.", - "homepage": "https://github.com/openclaw/clawscan#openclaw-install-gate", - "bugs": { - "url": "https://github.com/openclaw/clawscan/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/openclaw/clawscan.git" - }, - "files": [ - "index.ts", - "src/", - "profiles/", - "openclaw.plugin.json", - "LICENSE", - "README.md" - ], - "type": "module", - "publishConfig": { - "access": "public", - "provenance": true - }, - "scripts": { - "test": "node --test test/*.test.mjs test/*.test.ts" - }, - "dependencies": { - "@openclaw/clawscan": "0.0.0-dev" - }, - "engines": { - "node": ">=22.22.3" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "build": { - "openclawVersion": "2026.7.2", - "bundledDist": false - }, - "release": { - "publishToClawHub": false, - "publishToNpm": false, - "bundleRuntimeDependencies": false - } - } -} diff --git a/npm/clawscan-plugin/src/artifact.ts b/npm/clawscan-plugin/src/artifact.ts deleted file mode 100644 index 1530b82..0000000 --- a/npm/clawscan-plugin/src/artifact.ts +++ /dev/null @@ -1,345 +0,0 @@ -export type InstallFinding = { - ruleId: string; - severity: "info" | "warn" | "critical"; - file: string; - line: number; - message: string; -}; - -export type BeforeInstallResult = { - findings?: InstallFinding[]; - block?: boolean; - blockReason?: string; -}; - -const MAX_GATE_RULES = 100; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function scannerCompleted(value: unknown): value is Record { - return isRecord(value) && value.status === "completed"; -} - -function skillSpectorEvidenceUsable(raw: unknown): boolean { - if (!isRecord(raw) || raw.execution_successful === false || cleanText(raw.error, 1) !== "") { - return false; - } - const status = cleanText(raw.status, 40).toLowerCase(); - if (["benign", "safe", "clean", "suspicious", "malicious"].includes(status)) { - return true; - } - const assessment = isRecord(raw.risk_assessment) - ? raw.risk_assessment - : isRecord(raw.riskAssessment) - ? raw.riskAssessment - : {}; - if ( - cleanText(raw.recommendation, 1) !== "" || - cleanText(assessment.recommendation, 1) !== "" || - typeof raw.score === "number" || - typeof assessment.score === "number" - ) { - return true; - } - return ["filtered_findings", "filteredFindings", "findings", "issues", "vulnerabilities"].some( - (key) => Array.isArray(raw[key]), - ); -} - -function scannerEvidenceUsable(scanner: string, result: Record): boolean { - if (scanner === "clawscan-static") { - return ( - isRecord(result.raw) && - result.raw.schemaVersion === "clawscan-static-v1" && - Array.isArray(result.raw.findings) - ); - } - return scanner !== "skillspector" || skillSpectorEvidenceUsable(result.raw); -} - -function cleanText(value: unknown, limit: number): string { - if (typeof value !== "string") { - return ""; - } - return value - .replace(/[\u0000-\u001f\u007f]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); -} - -function cleanRuleSegment(value: unknown, fallback: string): string { - const cleaned = cleanText(value, 64) - .replace(/[^a-zA-Z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return cleaned || fallback; -} - -function cleanFindingFile(value: unknown): string { - const raw = cleanText(value, 1_000).replaceAll("\\", "/"); - const segments = raw - .split("/") - .filter((segment) => segment !== "" && segment !== "." && segment !== "..") - .map((segment) => - segment - .replace(/[^a-zA-Z0-9._ -]+/g, "-") - .replace(/^-+|-+$/g, "") - .trim(), - ) - .filter(Boolean); - return segments.join("/").slice(0, 240) || "."; -} - -function cleanFindingLine(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return 1; - } - return Math.min(1_000_000, Math.max(1, Math.trunc(value))); -} - -function firstText( - record: Record, - keys: readonly string[], - limit: number, -): string { - for (const key of keys) { - const value = cleanText(record[key], limit); - if (value !== "") { - return value; - } - } - return ""; -} - -function firstNumber(record: Record, keys: readonly string[]): number { - for (const key of keys) { - if (typeof record[key] === "number") { - return cleanFindingLine(record[key]); - } - } - return 1; -} - -function normalizeIdentifier(value: string): string { - return value.trim().toUpperCase().replaceAll(" ", "_").replaceAll("-", "_"); -} - -function evidenceRecordsForRule( - scanner: string, - raw: unknown, - rule: Record, -): Record[] { - if (!isRecord(raw)) { - return []; - } - const path = cleanText(rule.path, 240); - const pathRoot = path.includes("[]") ? path.slice(0, path.indexOf("[]")) : ""; - const keys = - scanner === "clawscan-static" - ? ["findings"] - : ["filtered_findings", "filteredFindings", "findings", "issues", "vulnerabilities"]; - const orderedKeys = pathRoot === "" ? keys : [pathRoot, ...keys.filter((key) => key !== pathRoot)]; - for (const key of orderedKeys) { - const value = raw[key]; - if (Array.isArray(value)) { - return value.filter(isRecord); - } - } - return []; -} - -function findingFromEvidence( - rule: Record, - evidence: Record, -): InstallFinding { - const scanner = cleanRuleSegment(rule.scanner, "unknown-scanner"); - const evidenceId = firstText(evidence, ["id", "rule_id", "ruleId", "issueId", "code"], 64); - const ruleName = cleanRuleSegment(evidenceId || rule.rule, "gate-rule"); - const title = - firstText(evidence, ["title", "description", "explanation", "message"], 240) || - `${cleanText(rule.scanner, 80)} finding ${evidenceId || cleanText(rule.rule, 80)}`; - const severity = firstText(evidence, ["severity", "risk_severity", "riskSeverity", "level"], 40); - return { - ruleId: `clawscan/${scanner}/${ruleName}`, - severity: rule.action === "block" ? "critical" : "warn", - file: cleanFindingFile(firstText(evidence, ["path", "file_path", "filePath", "file"], 1_000)), - line: firstNumber(evidence, ["line", "start_line", "startLine"]), - message: severity ? `${severity}: ${title}` : title, - }; -} - -function findingFromRule(rule: Record): InstallFinding { - const scanner = cleanRuleSegment(rule.scanner, "unknown-scanner"); - const ruleName = cleanRuleSegment(rule.rule, "gate-rule"); - const path = cleanText(rule.path, 240); - const value = - rule.value === undefined ? "" : cleanText(JSON.stringify(rule.value), 120); - const matched = path === "" ? "" : ` matched ${path}${value === "" ? "" : `=${value}`}`; - const title = `${cleanText(rule.scanner, 80)} fired ${cleanText(rule.rule, 80)}${matched}`; - return { - ruleId: `clawscan/${scanner}/${ruleName}`, - severity: rule.action === "block" ? "critical" : "warn", - file: ".", - line: 1, - message: title, - }; -} - -function findingsFromRule( - rule: Record, - scanners: Record, -): InstallFinding[] | undefined { - if ( - typeof rule.scanner !== "string" || - typeof rule.rule !== "string" || - (rule.action !== "warn" && rule.action !== "block") - ) { - return undefined; - } - const scannerResult = scanners[rule.scanner]; - const raw = isRecord(scannerResult) ? scannerResult.raw : undefined; - const expectedSeverity = typeof rule.value === "string" ? normalizeIdentifier(rule.value) : ""; - const evidence = evidenceRecordsForRule(rule.scanner, raw, rule).filter((entry) => { - if (expectedSeverity === "") { - return true; - } - const severity = firstText( - entry, - ["severity", "risk_severity", "riskSeverity", "level"], - 40, - ); - return severity !== "" && normalizeIdentifier(severity) === expectedSeverity; - }); - if (evidence.length === 0) { - return [findingFromRule(rule)]; - } - return evidence.map((entry) => findingFromEvidence(rule, entry)); -} - -function ruleReferencesAvailableScanner( - rule: Record, - scanners: Record, -): boolean { - return typeof rule.scanner === "string" && Object.hasOwn(scanners, rule.scanner); -} - -function blockForInvalidArtifact(reason: string): BeforeInstallResult { - return { - block: true, - blockReason: `ClawScan blocked installation: ${reason}`, - findings: [ - { - ruleId: "clawscan/artifact-invalid", - severity: "critical", - file: ".", - line: 1, - message: reason, - }, - ], - }; -} - -export function gateResultFromArtifact( - stdout: string, - requiredScanners: readonly string[], -): BeforeInstallResult | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { - return blockForInvalidArtifact("scanner output was not valid JSON"); - } - - if (!isRecord(parsed) || parsed.schemaVersion !== "clawscan-run-v1") { - return blockForInvalidArtifact("scanner output was not a clawscan-run-v1 artifact"); - } - if (!isRecord(parsed.scanners)) { - return blockForInvalidArtifact("scanner artifact did not contain scanner results"); - } - if (Object.keys(parsed.scanners).length === 0) { - return blockForInvalidArtifact("scanner artifact did not contain any scanner results"); - } - for (const scanner of requiredScanners) { - const result = parsed.scanners[scanner]; - if (!scannerCompleted(result)) { - return blockForInvalidArtifact(`required scanner ${scanner} did not complete`); - } - if (!scannerEvidenceUsable(scanner, result)) { - return blockForInvalidArtifact(`required scanner ${scanner} returned unusable evidence`); - } - } - for (const [scanner, result] of Object.entries(parsed.scanners)) { - if (!scannerCompleted(result)) { - const scannerName = cleanRuleSegment(scanner, "unknown-scanner"); - return blockForInvalidArtifact(`scanner ${scannerName} did not complete`); - } - } - - if (!Array.isArray(parsed.gateRules)) { - return blockForInvalidArtifact("scanner artifact did not contain fired gate rules"); - } - if (parsed.gateRules.length > MAX_GATE_RULES) { - return blockForInvalidArtifact("scanner artifact contained too many fired gate rules"); - } - if (parsed.gate === "pass") { - if (parsed.gateRules.length !== 0) { - return blockForInvalidArtifact("pass artifact unexpectedly contained fired gate rules"); - } - return undefined; - } - if (parsed.gate === "warn") { - const findings: InstallFinding[] = []; - for (const rule of parsed.gateRules) { - if (!isRecord(rule) || rule.action !== "warn") { - return blockForInvalidArtifact("warn artifact contained an invalid fired gate rule"); - } - if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { - return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); - } - const ruleFindings = findingsFromRule(rule, parsed.scanners); - if (!ruleFindings) { - return blockForInvalidArtifact("warn artifact contained an invalid fired gate rule"); - } - findings.push(...ruleFindings); - findings.length = Math.min(findings.length, MAX_GATE_RULES); - } - if (findings.length === 0) { - return blockForInvalidArtifact("warn artifact did not contain a fired warning rule"); - } - return { findings }; - } - if (parsed.gate === "block") { - const findings: InstallFinding[] = []; - for (const rule of parsed.gateRules) { - if (!isRecord(rule)) { - return blockForInvalidArtifact("block artifact contained an invalid fired gate rule"); - } - if (!ruleReferencesAvailableScanner(rule, parsed.scanners)) { - return blockForInvalidArtifact("fired gate rule referenced an unavailable scanner"); - } - const ruleFindings = findingsFromRule(rule, parsed.scanners); - if (!ruleFindings) { - return blockForInvalidArtifact("block artifact contained an invalid fired gate rule"); - } - findings.push(...ruleFindings); - findings.length = Math.min(findings.length, MAX_GATE_RULES); - } - const blockingMessages = findings - .filter((finding) => finding.severity === "critical") - .map((finding) => finding.message); - if (blockingMessages.length === 0) { - return blockForInvalidArtifact("block artifact did not contain a fired blocking rule"); - } - return { - block: true, - blockReason: cleanText( - `ClawScan gate blocked installation: ${blockingMessages.join("; ")}`, - 1_000, - ), - findings, - }; - } - return blockForInvalidArtifact("scanner artifact contained an unknown gate verdict"); -} diff --git a/npm/clawscan-plugin/src/gate-handler.ts b/npm/clawscan-plugin/src/gate-handler.ts deleted file mode 100644 index f2de532..0000000 --- a/npm/clawscan-plugin/src/gate-handler.ts +++ /dev/null @@ -1,228 +0,0 @@ -import process from "node:process"; -import { lstatSync } from "node:fs"; -import { join } from "node:path"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { gateResultFromArtifact, type BeforeInstallResult } from "./artifact.ts"; - -const DOCKER_PROBE_TIMEOUT_MS = 5_000; -const SCAN_TIMEOUT_MS = 600_000; -const MAX_STDOUT_BYTES = 8 * 1024 * 1024; -const MAX_STDERR_BYTES = 64 * 1024; - -type HostRunCommand = OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"]; - -export type CommandOptions = Exclude[1], number>; -export type CommandResult = Awaited>; - -export type GateHandlerDependencies = { - platform?: NodeJS.Platform; - runCommand: (argv: string[], options: CommandOptions) => Promise; - resolveBinaryPath: () => string; - resolveConfigPath: () => string; - resolveFallbackConfigPath?: () => string; - profile: string; - requiredScanners?: readonly string[]; -}; - -export type BeforeInstallEvent = { - sourcePath: string; - sourcePathKind: "file" | "directory"; - targetType: "skill" | "plugin"; -}; - -function isRegularFile(path: string): boolean { - try { - return lstatSync(path).isFile(); - } catch { - return false; - } -} - -export function scanTargetForEvent( - event: BeforeInstallEvent, - pluginManifestExists: (path: string) => boolean = isRegularFile, -): string { - if (event.sourcePathKind === "file") { - return event.sourcePath; - } - if (event.targetType === "plugin") { - const manifestPath = join(event.sourcePath, "openclaw.plugin.json"); - return pluginManifestExists(manifestPath) ? manifestPath : event.sourcePath; - } - return pluginManifestExists(join(event.sourcePath, "openclaw.plugin.json")) - ? join(event.sourcePath, "SKILL.md") - : event.sourcePath; -} - -function commandSucceeded(result: CommandResult): boolean { - return ( - result.code === 0 && - result.signal === null && - result.termination === "exit" && - result.outputLimitExceeded !== true - ); -} - -function cleanDiagnostic(message: string, limit = 600): string { - return message - .replace(/[\u0000-\u001f\u007f]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, limit); -} - -function failClosed(message: string): BeforeInstallResult { - const cleaned = cleanDiagnostic(message); - const reason = cleaned || "ClawScan could not complete the install-time scan"; - return { - block: true, - blockReason: `ClawScan blocked installation: ${reason}`, - findings: [ - { - ruleId: "clawscan/gate-failure", - severity: "critical", - file: ".", - line: 1, - message: reason, - }, - ], - }; -} - -function commandFailure(label: string, result: CommandResult): BeforeInstallResult { - if (result.outputLimitExceeded === true) { - return failClosed(`${label} exceeded its output limit`); - } - if (result.termination === "timeout" || result.termination === "no-output-timeout") { - return failClosed(`${label} timed out`); - } - if (result.signal !== null || result.termination === "signal") { - const signal = cleanDiagnostic(result.signal ?? "unknown signal", 40); - return failClosed(`${label} was terminated by ${signal}`); - } - if (typeof result.code === "number") { - const stderr = cleanDiagnostic(result.stderr, 480); - return failClosed(`${label} exited with code ${result.code}${stderr ? `: ${stderr}` : ""}`); - } - return failClosed(`${label} failed without an exit code`); -} - -function errorCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - return typeof error.code === "string" ? error.code : undefined; -} - -const degradedFinding = { - ruleId: "clawscan/docker-unavailable", - severity: "warn" as const, - file: ".", - line: 1, - message: "Gate degraded: Docker mode unavailable on this host; clawscan-static only.", -}; - -// The host command runner merges overrides with its ambient environment. Empty -// values prevent ClawScan from forwarding provider credentials into Docker. -const noLlmEnvironment = { - CLAWSCAN_SKILLSPECTOR_LLM: "0", - SKILLSPECTOR_PROVIDER: "", - SKILLSPECTOR_MODEL: "", - SKILLSPECTOR_MODEL_REGISTRY: "", - NVIDIA_INFERENCE_KEY: "", - OPENAI_API_KEY: "", - OPENAI_BASE_URL: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_PROXY_ENDPOINT_URL: "", - ANTHROPIC_PROXY_API_KEY: "", - ANTHROPIC_PROXY_API_VERSION: "", -}; - -const scanCommandOptions: CommandOptions = { - timeoutMs: SCAN_TIMEOUT_MS, - env: noLlmEnvironment, - killProcessTree: true, - maxOutputBytes: { - stdout: MAX_STDOUT_BYTES, - stderr: MAX_STDERR_BYTES, - }, - outputCapture: "head", - terminateOnOutputLimit: true, -}; - -export function createBeforeInstallHandler(dependencies: GateHandlerDependencies) { - return async (event: BeforeInstallEvent): Promise => { - try { - const scanTarget = scanTargetForEvent(event); - let dockerAvailable = false; - if ((dependencies.platform ?? process.platform) !== "win32") { - try { - const dockerProbe = await dependencies.runCommand(["docker", "info"], { - timeoutMs: DOCKER_PROBE_TIMEOUT_MS, - }); - dockerAvailable = commandSucceeded(dockerProbe); - } catch { - dockerAvailable = false; - } - } - const binaryPath = dependencies.resolveBinaryPath(); - if (!dockerAvailable) { - const fallbackConfigPath = - dependencies.resolveFallbackConfigPath?.() ?? dependencies.resolveConfigPath(); - const scan = await dependencies.runCommand( - [ - binaryPath, - scanTarget, - "--config", - fallbackConfigPath, - "--profile", - "clawhub-static", - "--scanner", - "clawscan-static", - "--sandbox", - "off", - "--json", - ], - scanCommandOptions, - ); - if (!commandSucceeded(scan)) { - const failure = commandFailure("ClawScan static fallback", scan); - return { - ...failure, - findings: [degradedFinding, ...(failure.findings ?? [])], - }; - } - const result = gateResultFromArtifact(scan.stdout, ["clawscan-static"]); - return { - ...result, - findings: [degradedFinding, ...(result?.findings ?? [])], - }; - } - - const configPath = dependencies.resolveConfigPath(); - const scan = await dependencies.runCommand( - [ - binaryPath, - scanTarget, - "--config", - configPath, - "--profile", - dependencies.profile, - "--sandbox", - "docker", - "--json", - ], - scanCommandOptions, - ); - if (!commandSucceeded(scan)) { - return commandFailure("ClawScan process", scan); - } - return gateResultFromArtifact(scan.stdout, dependencies.requiredScanners ?? []); - } catch (error) { - if (errorCode(error) === "ENOENT") { - return failClosed("ClawScan binary was not found"); - } - return failClosed("ClawScan could not start the install-time scan"); - } - }; -} diff --git a/npm/clawscan-plugin/src/register.ts b/npm/clawscan-plugin/src/register.ts deleted file mode 100644 index ad4accf..0000000 --- a/npm/clawscan-plugin/src/register.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createBeforeInstallHandler, - type BeforeInstallEvent, - type CommandOptions, - type CommandResult, -} from "./gate-handler.ts"; -import type { BeforeInstallResult } from "./artifact.ts"; - -const DEFAULT_CONFIG_PATH = "profiles/clawhub.yml"; -const DEFAULT_PROFILE = "clawhub"; -const HOOK_TIMEOUT_MS = 615_000; - -export type RegisteredHandler = ( - event: BeforeInstallEvent, -) => Promise; - -export type GatePluginApi = { - pluginConfig?: Record; - resolvePath: (input: string) => string; - runtime: { - system: { - runCommandWithTimeout: (argv: string[], options: CommandOptions) => Promise; - }; - }; - on: ( - name: "before_install", - handler: RegisteredHandler, - options: { priority: number; timeoutMs: number }, - ) => void; -}; - -function configuredString( - pluginConfig: Record | undefined, - key: string, - fallback: string, -): string { - const value = pluginConfig?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : fallback; -} - -export function requiredScannersForShippedConfig( - configPath: string, - defaultConfigPath: string, - profile: string, -): readonly string[] { - if (configPath !== defaultConfigPath) { - return []; - } - if (profile === "clawhub") { - return ["skillspector", "clawscan-static"]; - } - if (profile === "clawhub-static") { - return ["clawscan-static"]; - } - return []; -} - -export function registerInstallGate(api: GatePluginApi, resolveBinaryPath: () => string): void { - const configPath = configuredString(api.pluginConfig, "configPath", DEFAULT_CONFIG_PATH); - const profile = configuredString(api.pluginConfig, "profile", DEFAULT_PROFILE); - const resolvedConfigPath = api.resolvePath(configPath); - const resolvedDefaultConfigPath = api.resolvePath(DEFAULT_CONFIG_PATH); - const handler = createBeforeInstallHandler({ - resolveBinaryPath, - resolveConfigPath: () => resolvedConfigPath, - resolveFallbackConfigPath: () => resolvedDefaultConfigPath, - profile, - requiredScanners: requiredScannersForShippedConfig( - resolvedConfigPath, - resolvedDefaultConfigPath, - profile, - ), - runCommand: async (argv, options) => - await api.runtime.system.runCommandWithTimeout(argv, options), - }); - - api.on("before_install", handler, { - priority: 100, - timeoutMs: HOOK_TIMEOUT_MS, - }); -} diff --git a/npm/clawscan-plugin/test/artifact.test.ts b/npm/clawscan-plugin/test/artifact.test.ts deleted file mode 100644 index d4ea3aa..0000000 --- a/npm/clawscan-plugin/test/artifact.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { gateResultFromArtifact } from "../src/artifact.ts"; - -const skillSpectorCompleted = { - status: "completed", - error: "", - raw: { status: "clean", findings: [] }, -}; -const staticCompleted = { - status: "completed", - error: "", - raw: { schemaVersion: "clawscan-static-v1", findings: [] }, -}; - -describe("gateResultFromArtifact", () => { - it("continues silently for a valid pass artifact", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - skillspector: skillSpectorCompleted, - "clawscan-static": staticCompleted, - }, - }), - ["skillspector", "clawscan-static"], - ); - - assert.equal(result, undefined); - }); - - it("maps every fired warning to a structured non-blocking finding", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "warn", - gateRules: [ - { - scanner: "skillspector", - rule: "high-finding", - path: "filtered_findings[].severity", - value: "HIGH", - action: "warn", - }, - { - scanner: "clawscan-static", - rule: "any-finding", - path: "findings[]", - action: "warn", - }, - ], - scanners: { - skillspector: { - status: "completed", - error: "", - raw: { - filtered_findings: [ - { - rule_id: "SS-101", - severity: "HIGH", - file_path: "package.json", - start_line: 12, - description: "Suspicious package script", - }, - ], - }, - }, - "clawscan-static": { - status: "completed", - error: "", - raw: { - schemaVersion: "clawscan-static-v1", - findings: [ - { - id: "prompt-injection", - severity: "high", - path: "SKILL.md", - line: 4, - title: "Prompt injection language", - }, - ], - }, - }, - }, - }), - ["skillspector", "clawscan-static"], - ); - - assert.deepEqual(result, { - findings: [ - { - ruleId: "clawscan/skillspector/SS-101", - severity: "warn", - file: "package.json", - line: 12, - message: "HIGH: Suspicious package script", - }, - { - ruleId: "clawscan/clawscan-static/prompt-injection", - severity: "warn", - file: "SKILL.md", - line: 4, - message: "high: Prompt injection language", - }, - ], - }); - }); - - it("evaluates valid evidence from a completed scanner with a nonzero-exit error", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "warn", - gateRules: [ - { - scanner: "skillspector", - rule: "high-finding", - path: "issues[].severity", - value: "HIGH", - action: "warn", - }, - ], - scanners: { - skillspector: { - status: "completed", - error: "scanner exited with code 1", - exitCode: 1, - raw: { - risk_assessment: { severity: "HIGH" }, - issues: [ - { - id: "SS-101", - severity: "HIGH", - path: "package.json", - line: 12, - description: "Suspicious package script", - }, - ], - }, - }, - }, - }), - ["skillspector"], - ); - - assert.deepEqual(result, { - findings: [ - { - ruleId: "clawscan/skillspector/SS-101", - severity: "warn", - file: "package.json", - line: 12, - message: "HIGH: Suspicious package script", - }, - ], - }); - }); - - it("maps a block artifact to an explicit block with its fired findings", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "block", - gateRules: [ - { - scanner: "skillspector", - rule: "critical-finding", - path: "filtered_findings[].severity", - value: "CRITICAL", - action: "block", - }, - ], - scanners: { - skillspector: { - status: "completed", - error: "", - raw: { - filtered_findings: [ - { - rule_id: "SS-900", - severity: "CRITICAL", - file_path: "SKILL.md", - start_line: 9, - description: "Credential theft behavior", - }, - ], - }, - }, - "clawscan-static": staticCompleted, - }, - }), - ["skillspector", "clawscan-static"], - ); - - assert.deepEqual(result, { - block: true, - blockReason: "ClawScan gate blocked installation: CRITICAL: Credential theft behavior", - findings: [ - { - ruleId: "clawscan/skillspector/SS-900", - severity: "critical", - file: "SKILL.md", - line: 9, - message: "CRITICAL: Credential theft behavior", - }, - ], - }); - }); - - it("bounds and sanitizes untrusted fired-rule text, file paths, and line numbers", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "warn", - gateRules: [ - { - scanner: "demo scanner\u0000", - rule: "any-finding", - path: "findings[]", - action: "warn", - }, - ], - scanners: { - "demo scanner\u0000": { - status: "completed", - raw: { - findings: [ - { - id: "odd rule/id", - title: `unsafe\u0000 title ${"x".repeat(400)}`, - severity: "HIGH", - path: "/../../private/\u0000token.ts", - line: 9_999_999, - }, - ], - }, - }, - }, - }), - ["demo scanner\u0000"], - ); - - assert.ok(result?.findings); - assert.equal(result.findings[0]?.ruleId, "clawscan/demo-scanner/odd-rule-id"); - assert.equal(result.findings[0]?.file, "private/token.ts"); - assert.equal(result.findings[0]?.line, 1_000_000); - assert.ok((result.findings[0]?.message.length ?? 0) <= 282); - assert.doesNotMatch(result.findings[0]?.message ?? "", /[\u0000-\u001f\u007f]/); - }); - - for (const fixture of [ - { - name: "malformed JSON", - stdout: "{", - requiredScanners: ["skillspector"], - }, - { - name: "an unknown gate verdict", - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "maybe", - gateRules: [], - scanners: { skillspector: skillSpectorCompleted }, - }), - requiredScanners: ["skillspector"], - }, - { - name: "a missing required scanner", - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: {}, - }), - requiredScanners: ["skillspector"], - }, - ...["skipped", "completed"].map((status) => ({ - name: `a ${status} required scanner`, - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { skillspector: { status, error: "untrusted scanner error" } }, - }), - requiredScanners: ["skillspector"], - })), - ]) { - it(`fails closed for ${fixture.name}`, () => { - const result = gateResultFromArtifact(fixture.stdout, fixture.requiredScanners); - - assert.equal(result?.block, true); - assert.match(result?.blockReason ?? "", /^ClawScan blocked installation:/); - assert.equal(result?.findings?.[0]?.severity, "critical"); - assert.doesNotMatch(result?.blockReason ?? "", /untrusted scanner error/); - }); - } - - it("fails closed when any additional profile scanner does not complete", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - skillspector: skillSpectorCompleted, - "clawscan-static": staticCompleted, - "team-scanner": { status: "failed" }, - }, - }), - ["skillspector", "clawscan-static"], - ); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - "ClawScan blocked installation: scanner team-scanner did not complete", - ); - }); - - for (const [name, scanner, raw] of [ - ["completion-only SkillSpector", "skillspector", { status: "completed" }], - [ - "failed SkillSpector execution", - "skillspector", - { status: "clean", execution_successful: false }, - ], - ["invalid static scanner", "clawscan-static", { schemaVersion: "wrong", findings: [] }], - ] as const) { - it(`fails closed for ${name} evidence`, () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { [scanner]: { status: "completed", error: "", raw } }, - }), - [scanner], - ); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - `ClawScan blocked installation: required scanner ${scanner} returned unusable evidence`, - ); - }); - } - - it("fails closed when an artifact contains no scanner results", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: {}, - }), - [], - ); - - assert.equal( - result?.blockReason, - "ClawScan blocked installation: scanner artifact did not contain any scanner results", - ); - }); - - it("fails closed when a fired rule names a scanner outside the artifact", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "warn", - gateRules: [ - { - scanner: "invented-scanner", - rule: "nativeFinding", - action: "warn", - }, - ], - scanners: { - skillspector: skillSpectorCompleted, - "clawscan-static": staticCompleted, - }, - }), - ["skillspector", "clawscan-static"], - ); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - "ClawScan blocked installation: fired gate rule referenced an unavailable scanner", - ); - }); - - it("fails closed instead of returning an unbounded finding list", () => { - const result = gateResultFromArtifact( - JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "warn", - gateRules: Array.from({ length: 101 }, (_, index) => ({ - scanner: "clawscan-static", - rule: "nativeFinding", - findingCode: `finding-${index}`, - action: "warn", - })), - scanners: { - "clawscan-static": staticCompleted, - }, - }), - ["clawscan-static"], - ); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - "ClawScan blocked installation: scanner artifact contained too many fired gate rules", - ); - assert.equal(result?.findings?.length, 1); - }); -}); diff --git a/npm/clawscan-plugin/test/gate-handler.test.ts b/npm/clawscan-plugin/test/gate-handler.test.ts deleted file mode 100644 index fb36651..0000000 --- a/npm/clawscan-plugin/test/gate-handler.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - createBeforeInstallHandler, - scanTargetForEvent, - type BeforeInstallEvent, - type CommandOptions, - type CommandResult, -} from "../src/gate-handler.ts"; - -type CommandCall = { - argv: string[]; - options: CommandOptions; -}; - -const staticCompleted = { - status: "completed", - error: "", - raw: { schemaVersion: "clawscan-static-v1", findings: [] }, -}; - -const passArtifact = JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - skillspector: { status: "completed", error: "", raw: { status: "clean", findings: [] } }, - "clawscan-static": staticCompleted, - }, -}); - -function commandResult(overrides: Partial = {}): CommandResult { - return { - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit", - ...overrides, - }; -} - -function beforeInstallEvent(overrides: Partial = {}): BeforeInstallEvent { - return { - sourcePath: "/candidate/demo-skill", - sourcePathKind: "directory", - targetType: "skill", - ...overrides, - }; -} - -describe("createBeforeInstallHandler", () => { - it("runs the full shipped profile and continues silently for a pass artifact", async () => { - const calls: CommandCall[] = []; - const outputs = [commandResult(), commandResult({ stdout: passArtifact })]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - requiredScanners: ["skillspector", "clawscan-static"], - runCommand: async (argv, options) => { - calls.push({ argv, options }); - return outputs.shift() ?? commandResult(); - }, - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result, undefined); - assert.deepEqual(calls, [ - { - argv: ["docker", "info"], - options: { timeoutMs: 5_000 }, - }, - { - argv: [ - "/plugin/node_modules/@openclaw/clawscan/binaries/clawscan", - "/candidate/demo-skill", - "--config", - "/plugin/profiles/clawhub.yml", - "--profile", - "clawhub", - "--sandbox", - "docker", - "--json", - ], - options: { - timeoutMs: 600_000, - env: { - CLAWSCAN_SKILLSPECTOR_LLM: "0", - SKILLSPECTOR_PROVIDER: "", - SKILLSPECTOR_MODEL: "", - SKILLSPECTOR_MODEL_REGISTRY: "", - NVIDIA_INFERENCE_KEY: "", - OPENAI_API_KEY: "", - OPENAI_BASE_URL: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_PROXY_ENDPOINT_URL: "", - ANTHROPIC_PROXY_API_KEY: "", - ANTHROPIC_PROXY_API_VERSION: "", - }, - killProcessTree: true, - maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, - outputCapture: "head", - terminateOnOutputLimit: true, - }, - }, - ]); - }); - - it("degrades visibly to the static scanner with exact safe arguments when Docker is unavailable", async () => { - const calls: CommandCall[] = []; - const staticPassArtifact = JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - "clawscan-static": staticCompleted, - }, - }); - const outputs = [ - commandResult({ code: 1, stderr: "daemon unavailable" }), - commandResult({ stdout: staticPassArtifact }), - ]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async (argv, options) => { - calls.push({ argv, options }); - return outputs.shift() ?? commandResult(); - }, - }); - - const result = await handler(beforeInstallEvent()); - - assert.deepEqual(result, { - findings: [ - { - ruleId: "clawscan/docker-unavailable", - severity: "warn", - file: ".", - line: 1, - message: "Gate degraded: Docker mode unavailable on this host; clawscan-static only.", - }, - ], - }); - assert.deepEqual(calls[1], { - argv: [ - "/plugin/bin/clawscan", - "/candidate/demo-skill", - "--config", - "/plugin/profiles/clawhub.yml", - "--profile", - "clawhub-static", - "--scanner", - "clawscan-static", - "--sandbox", - "off", - "--json", - ], - options: { - timeoutMs: 600_000, - env: { - CLAWSCAN_SKILLSPECTOR_LLM: "0", - SKILLSPECTOR_PROVIDER: "", - SKILLSPECTOR_MODEL: "", - SKILLSPECTOR_MODEL_REGISTRY: "", - NVIDIA_INFERENCE_KEY: "", - OPENAI_API_KEY: "", - OPENAI_BASE_URL: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_PROXY_ENDPOINT_URL: "", - ANTHROPIC_PROXY_API_KEY: "", - ANTHROPIC_PROXY_API_VERSION: "", - }, - killProcessTree: true, - maxOutputBytes: { stdout: 8_388_608, stderr: 65_536 }, - outputCapture: "head", - terminateOnOutputLimit: true, - }, - }); - }); - - it("validates the scanners selected by a shipped non-default profile", async () => { - const calls: CommandCall[] = []; - const outputs = [ - commandResult(), - commandResult({ - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - "clawscan-static": staticCompleted, - }, - }), - }), - ]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub-static", - requiredScanners: ["clawscan-static"], - runCommand: async (argv, options) => { - calls.push({ argv, options }); - return outputs.shift() ?? commandResult(); - }, - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result, undefined); - assert.ok(calls[1]?.argv.includes("clawhub-static")); - }); - - it("treats a missing Docker command as degraded mode instead of skipping the scan", async () => { - let invocation = 0; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => { - invocation += 1; - if (invocation === 1) { - throw Object.assign(new Error("spawn docker ENOENT"), { code: "ENOENT" }); - } - return commandResult({ - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { "clawscan-static": staticCompleted }, - }), - }); - }, - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(invocation, 2); - assert.equal(result?.block, undefined); - assert.equal(result?.findings?.[0]?.ruleId, "clawscan/docker-unavailable"); - }); - - it("uses the static degraded path on Windows even when Docker may be installed", async () => { - const calls: CommandCall[] = []; - const handler = createBeforeInstallHandler({ - platform: "win32", - resolveBinaryPath: () => "C:\\plugin\\clawscan.exe", - resolveConfigPath: () => "C:\\plugin\\profiles\\clawhub.yml", - profile: "clawhub", - runCommand: async (argv, options) => { - calls.push({ argv, options }); - return commandResult({ - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { "clawscan-static": staticCompleted }, - }), - }); - }, - }); - - const result = await handler(beforeInstallEvent({ sourcePath: "C:\\candidate\\demo-skill" })); - - assert.equal(calls.length, 1); - assert.equal(calls[0]?.argv[0], "C:\\plugin\\clawscan.exe"); - assert.equal(calls[0]?.argv.includes("docker"), false); - assert.equal(result?.findings?.[0]?.ruleId, "clawscan/docker-unavailable"); - }); - - it("blocks with bounded sanitized stderr when the ClawScan process exits nonzero", async () => { - const outputs = [ - commandResult(), - commandResult({ - code: 17, - stderr: `bad\u0000 output ${"x".repeat(1_000)}`, - }), - ]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => outputs.shift() ?? commandResult(), - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result?.block, true); - assert.match( - result?.blockReason ?? "", - /^ClawScan blocked installation: ClawScan process exited with code 17: bad output/, - ); - assert.ok((result?.blockReason?.length ?? 0) <= 631); - assert.doesNotMatch(result?.blockReason ?? "", /[\u0000-\u001f\u007f]/); - }); - - it("blocks explicitly when the resolved ClawScan binary is missing", async () => { - let invocation = 0; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => { - invocation += 1; - if (invocation === 1) { - return commandResult(); - } - throw Object.assign(new Error("spawn /private/plugin/bin/clawscan ENOENT"), { - code: "ENOENT", - }); - }, - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - "ClawScan blocked installation: ClawScan binary was not found", - ); - assert.doesNotMatch(result?.blockReason ?? "", /\/private\/plugin/); - }); - - for (const fixture of [ - { - name: "timeout", - result: commandResult({ code: null, termination: "timeout" }), - reason: "ClawScan blocked installation: ClawScan process timed out", - }, - { - name: "signal", - result: commandResult({ code: null, signal: "SIGTERM", termination: "signal" }), - reason: "ClawScan blocked installation: ClawScan process was terminated by SIGTERM", - }, - ]) { - it(`blocks explicitly on process ${fixture.name}`, async () => { - const outputs = [commandResult(), fixture.result]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => outputs.shift() ?? commandResult(), - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result?.block, true); - assert.equal(result?.blockReason, fixture.reason); - }); - } - - it("blocks when the static fallback fails and keeps degraded mode visible", async () => { - const outputs = [ - commandResult({ code: 1 }), - commandResult({ code: 23, stderr: "static scan failed" }), - ]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => outputs.shift() ?? commandResult(), - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal(result?.block, true); - assert.equal( - result?.blockReason, - "ClawScan blocked installation: ClawScan static fallback exited with code 23: static scan failed", - ); - assert.deepEqual( - result?.findings?.map((finding) => finding.ruleId), - ["clawscan/docker-unavailable", "clawscan/gate-failure"], - ); - }); - - it("blocks explicitly when scanner output exceeds the host capture limit", async () => { - const outputs = [ - commandResult(), - commandResult({ - code: null, - signal: "SIGTERM", - termination: "signal", - outputLimitExceeded: true, - }), - ]; - const handler = createBeforeInstallHandler({ - resolveBinaryPath: () => "/plugin/bin/clawscan", - resolveConfigPath: () => "/plugin/profiles/clawhub.yml", - profile: "clawhub", - runCommand: async () => outputs.shift() ?? commandResult(), - }); - - const result = await handler(beforeInstallEvent()); - - assert.equal( - result?.blockReason, - "ClawScan blocked installation: ClawScan process exceeded its output limit", - ); - }); -}); - -describe("scanTargetForEvent", () => { - it("scans full skill directories and disambiguates dual-layout candidates", () => { - assert.equal(scanTargetForEvent(beforeInstallEvent()), "/candidate/demo-skill"); - assert.equal( - scanTargetForEvent(beforeInstallEvent(), () => true), - "/candidate/demo-skill/SKILL.md", - ); - assert.equal( - scanTargetForEvent( - beforeInstallEvent({ - sourcePath: "/candidate/demo-plugin", - targetType: "plugin", - }), - () => true, - ), - "/candidate/demo-plugin/openclaw.plugin.json", - ); - assert.equal( - scanTargetForEvent( - beforeInstallEvent({ - sourcePath: "/candidate/codex-bundle", - targetType: "plugin", - }), - () => false, - ), - "/candidate/codex-bundle", - ); - }); - - it("preserves file candidates selected by the host", () => { - assert.equal( - scanTargetForEvent( - beforeInstallEvent({ - sourcePath: "/candidate/plugin.tgz", - sourcePathKind: "file", - targetType: "plugin", - }), - ), - "/candidate/plugin.tgz", - ); - }); -}); diff --git a/npm/clawscan-plugin/test/package.test.mjs b/npm/clawscan-plugin/test/package.test.mjs deleted file mode 100644 index 2a5468b..0000000 --- a/npm/clawscan-plugin/test/package.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { describe, it } from "node:test"; - -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -async function readJson(path) { - return JSON.parse(await readFile(path, "utf8")); -} - -describe("@openclaw/clawscan-plugin package", () => { - it("declares the install gate manifest and an exact matching binary dependency", async () => { - const packageJson = await readJson(join(packageRoot, "package.json")); - const manifest = await readJson(join(packageRoot, "openclaw.plugin.json")); - - assert.equal(packageJson.name, "@openclaw/clawscan-plugin"); - assert.equal(packageJson.version, "0.0.0-dev"); - assert.equal(packageJson.private, true); - assert.equal(packageJson.dependencies["@openclaw/clawscan"], packageJson.version); - assert.equal(packageJson.peerDependencies, undefined); - assert.deepEqual(packageJson.openclaw.extensions, ["./index.ts"]); - assert.equal(packageJson.openclaw.install, undefined); - assert.equal(packageJson.openclaw.compat, undefined); - assert.equal(packageJson.openclaw.release.publishToClawHub, false); - assert.equal(packageJson.openclaw.release.publishToNpm, false); - assert.equal(manifest.id, "clawscan"); - assert.equal(manifest.activation.onStartup, false); - assert.deepEqual(manifest.activation.onHooks, ["before_install"]); - assert.deepEqual(manifest.activation.onCapabilities, ["hook"]); - assert.equal(manifest.enabledByDefault, undefined); - }); - - it("packs the manifest and profile without tests or install-time lifecycle bypasses", async () => { - const packageJson = await readJson(join(packageRoot, "package.json")); - const packed = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { - cwd: packageRoot, - encoding: "utf8", - }); - assert.equal(packed.status, 0, packed.stderr); - const report = JSON.parse(packed.stdout)[0]; - const files = report.files.map((entry) => entry.path).sort(); - - assert.ok(files.includes("openclaw.plugin.json")); - assert.ok(files.includes("profiles/clawhub.yml")); - assert.ok(files.includes("index.ts")); - assert.ok(files.includes("src/gate-handler.ts")); - assert.equal( - files.some((path) => path.startsWith("test/")), - false, - ); - assert.equal(packageJson.scripts?.preinstall, undefined); - assert.equal(packageJson.scripts?.install, undefined); - assert.equal(packageJson.scripts?.postinstall, undefined); - }); - - it("keeps the entrypoint free of direct process-spawning imports", async () => { - const entrypoint = await readFile(join(packageRoot, "index.ts"), "utf8"); - const register = await readFile(join(packageRoot, "src", "register.ts"), "utf8"); - const handler = await readFile(join(packageRoot, "src", "gate-handler.ts"), "utf8"); - const forbiddenModule = ["node:child", "process"].join("_"); - - assert.doesNotMatch(entrypoint, new RegExp(forbiddenModule)); - assert.doesNotMatch(register, new RegExp(forbiddenModule)); - assert.doesNotMatch(handler, new RegExp(forbiddenModule)); - }); - - it("ships a no-judge profile with both required declarative gate scanners", async () => { - const profile = await readFile(join(packageRoot, "profiles", "clawhub.yml"), "utf8"); - - assert.match(profile, /id: skillspector/); - assert.match(profile, /id: clawscan-static/); - assert.match(profile, /id: execution-failed/); - assert.match(profile, /id: critical-finding/); - assert.match(profile, /id: any-finding/); - assert.doesNotMatch(profile, /native:/); - assert.doesNotMatch(profile, /\bjudge:/); - }); -}); diff --git a/npm/clawscan-plugin/test/registration.test.ts b/npm/clawscan-plugin/test/registration.test.ts deleted file mode 100644 index 26afb25..0000000 --- a/npm/clawscan-plugin/test/registration.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { BeforeInstallEvent } from "../src/gate-handler.ts"; -import { - registerInstallGate, - requiredScannersForShippedConfig, - type RegisteredHandler, -} from "../src/register.ts"; - -describe("registerInstallGate", () => { - it("recognizes equivalent resolved paths to the shipped profile", () => { - assert.deepEqual( - requiredScannersForShippedConfig( - "/plugin/profiles/clawhub.yml", - "/plugin/profiles/clawhub.yml", - "clawhub", - ), - ["skillspector", "clawscan-static"], - ); - }); - - it("registers a high-priority before_install hook with an explicit resolved config", async () => { - let registeredHandler: RegisteredHandler | undefined; - const commandCalls: string[][] = []; - registerInstallGate( - { - pluginConfig: { - configPath: "/trusted/custom.yml", - profile: "clawhub", - }, - resolvePath: (input) => input, - runtime: { - system: { - runCommandWithTimeout: async (argv) => { - commandCalls.push(argv); - if (argv[0] === "docker") { - return { - code: 0, - stdout: "", - stderr: "", - signal: null, - killed: false, - termination: "exit", - }; - } - return { - code: 0, - stdout: JSON.stringify({ - schemaVersion: "clawscan-run-v1", - gate: "pass", - gateRules: [], - scanners: { - "team-scanner": { status: "completed" }, - }, - }), - stderr: "", - signal: null, - killed: false, - termination: "exit", - }; - }, - }, - }, - on: (name, handler, options) => { - assert.equal(name, "before_install"); - assert.deepEqual(options, { priority: 100, timeoutMs: 615_000 }); - registeredHandler = handler; - }, - }, - () => "/plugin/bin/clawscan", - ); - - assert.ok(registeredHandler); - const result = await registeredHandler({ - sourcePath: "/untrusted/candidate", - sourcePathKind: "directory", - targetType: "skill", - } satisfies BeforeInstallEvent); - - assert.equal(result, undefined); - assert.deepEqual(commandCalls[1], [ - "/plugin/bin/clawscan", - "/untrusted/candidate", - "--config", - "/trusted/custom.yml", - "--profile", - "clawhub", - "--sandbox", - "docker", - "--json", - ]); - }); -}); diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index d0085af..1b88027 100644 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -13,6 +13,7 @@ const pages = [ ['index.md', 'Introduction'], ['scanners.md', 'Scanners'], ['profiles.md', 'Profiles'], + ['openclaw-install-policy.md', 'OpenClaw install policy'], ['judge.md', 'Judge'], ['sandbox.md', 'Sandbox'], ['benchmarks.md', 'Benchmarks'], @@ -20,7 +21,7 @@ const pages = [ const navSections = [ ['Start', ['index.md']], - ['Run', ['scanners.md', 'profiles.md', 'judge.md', 'sandbox.md', 'benchmarks.md']], + ['Run', ['scanners.md', 'profiles.md', 'openclaw-install-policy.md', 'judge.md', 'sandbox.md', 'benchmarks.md']], ]; let css = ` diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 4b8d027..2c3a34c 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -58,45 +57,6 @@ export function binaryNameForTarget(target) { return target.goos === "windows" ? "clawscan.exe" : "clawscan"; } -export function preparePluginPackageJson(packageJson, packageVersion) { - return { - ...packageJson, - version: packageVersion, - files: [...new Set([...(packageJson.files ?? []), "dist/"])], - dependencies: { - ...packageJson.dependencies, - "@openclaw/clawscan": packageVersion, - }, - openclaw: { - ...packageJson.openclaw, - runtimeExtensions: ["./dist/index.js"], - }, - }; -} - -const pluginRuntimeSources = [ - "index.ts", - join("src", "artifact.ts"), - join("src", "gate-handler.ts"), - join("src", "register.ts"), -]; - -export function compilePluginTypeScript(source) { - return stripTypeScriptTypes(source, { mode: "transform" }).replace( - /((?:from\s+|import\s*)["'](?:\.\.?\/)[^"']+)\.ts(["'])/gu, - "$1.js$2", - ); -} - -async function stagePluginRuntime(pluginPackageSource, pluginPackageOut) { - for (const relativeSource of pluginRuntimeSources) { - const destination = join(pluginPackageOut, "dist", relativeSource.replace(/\.ts$/u, ".js")); - await mkdir(dirname(destination), { recursive: true }); - const source = await readFile(join(pluginPackageSource, relativeSource), "utf8"); - await writeFile(destination, compilePluginTypeScript(source)); - } -} - function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? repoRoot, @@ -145,7 +105,7 @@ function parseArgs(argv) { return options; } -async function stagePackages(options) { +async function stagePackage(options) { const packageVersion = normalizePackageVersion(options.version); const binaryVersion = binaryVersionFor(options.version); const releaseSha = run("git", ["rev-parse", "HEAD"]).stdout.trim(); @@ -154,13 +114,10 @@ async function stagePackages(options) { run("git", ["show", "-s", "--format=%cI", "HEAD"]).stdout.trim(), ); const packageSource = join(repoRoot, "npm", "clawscan"); - const pluginPackageSource = join(repoRoot, "npm", "clawscan-plugin"); const packageOut = join(options.outDir, "package"); - const pluginPackageOut = join(options.outDir, "clawscan-plugin-package"); await rm(options.outDir, { recursive: true, force: true }); await mkdir(packageOut, { recursive: true }); - await mkdir(pluginPackageOut, { recursive: true }); await cp(packageSource, packageOut, { recursive: true, filter: (source) => !source.includes(`${join("npm", "clawscan", "test")}`), @@ -170,24 +127,11 @@ async function stagePackages(options) { await cp(join(repoRoot, "README.md"), join(packageOut, "README.md")); await cp(join(repoRoot, "LICENSE"), join(packageOut, "LICENSE")); await chmod(join(packageOut, "bin", "clawscan.js"), 0o755); - await cp(pluginPackageSource, pluginPackageOut, { - recursive: true, - filter: (source) => !source.includes(`${join("npm", "clawscan-plugin", "test")}`), - }); - await rm(join(pluginPackageOut, "test"), { recursive: true, force: true }); - await cp(join(repoRoot, "LICENSE"), join(pluginPackageOut, "LICENSE")); - await stagePluginRuntime(pluginPackageSource, pluginPackageOut); const packageJsonPath = join(packageOut, "package.json"); const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); packageJson.version = packageVersion; await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - const pluginPackageJsonPath = join(pluginPackageOut, "package.json"); - const pluginPackageJson = preparePluginPackageJson( - JSON.parse(await readFile(pluginPackageJsonPath, "utf8")), - packageVersion, - ); - await writeFile(pluginPackageJsonPath, `${JSON.stringify(pluginPackageJson, null, 2)}\n`); const ldflags = `-s -w -X main.version=${binaryVersion} -X main.commit=${releaseCommit} -X main.date=${buildDate}`; for (const target of packageTargets) { @@ -219,7 +163,7 @@ async function stagePackages(options) { await writeFile(join(options.outDir, "release-sha.txt"), `${releaseSha}\n`); await writeFile(join(options.outDir, "package-version.txt"), `${packageVersion}\n`); - return { binaryVersion, packageOut, packageVersion, pluginPackageOut, releaseSha }; + return { binaryVersion, packageOut, packageVersion, releaseSha }; } async function packPackage(options, packageOut) { @@ -236,16 +180,10 @@ async function packPackage(options, packageOut) { return resolve(options.outDir, first.filename); } -async function smokePackages( - clawscanTarballPath, - pluginTarballPath, - binaryVersion, - packageVersion, -) { +async function smokePackage(tarballPath, binaryVersion) { const prefix = await mkdtemp(join(tmpdir(), "clawscan-npm-smoke-")); - const pluginPrefix = await mkdtemp(join(tmpdir(), "clawscan-plugin-npm-smoke-")); try { - run("npm", ["install", "-g", "--prefix", prefix, clawscanTarballPath]); + run("npm", ["install", "-g", "--prefix", prefix, tarballPath]); const binPath = process.platform === "win32" ? join(prefix, "clawscan.cmd") : join(prefix, "bin", "clawscan"); const version = run(binPath, ["--version"]).stdout.trim(); @@ -259,97 +197,25 @@ async function smokePackages( "--json", ]); JSON.parse(smoke.stdout); - - run("npm", ["install", "--prefix", pluginPrefix, clawscanTarballPath]); - run("npm", ["install", "--prefix", pluginPrefix, pluginTarballPath]); - const installedPluginRoot = join(pluginPrefix, "node_modules", "@openclaw", "clawscan-plugin"); - const installedPackageJson = JSON.parse( - await readFile(join(installedPluginRoot, "package.json"), "utf8"), - ); - if ( - installedPackageJson.version !== packageVersion || - installedPackageJson.dependencies?.["@openclaw/clawscan"] !== packageVersion || - installedPackageJson.private !== true || - installedPackageJson.peerDependencies !== undefined || - installedPackageJson.openclaw?.install !== undefined || - installedPackageJson.openclaw?.compat !== undefined || - installedPackageJson.openclaw?.release?.publishToNpm !== false || - installedPackageJson.openclaw?.release?.publishToClawHub !== false - ) { - throw new Error( - "Installed ClawScan plugin did not preserve its private preview and binary contracts.", - ); - } - await readFile(join(installedPluginRoot, "openclaw.plugin.json"), "utf8"); - await readFile(join(installedPluginRoot, "profiles", "clawhub.yml"), "utf8"); - await readFile(join(installedPluginRoot, "dist", "index.js"), "utf8"); - - const hostPackageRoot = join(pluginPrefix, "node_modules", "openclaw"); - await mkdir(join(hostPackageRoot, "plugin-sdk"), { recursive: true }); - await writeFile( - join(hostPackageRoot, "package.json"), - `${JSON.stringify( - { - name: "openclaw", - version: "2026.7.2", - type: "module", - exports: { - "./plugin-sdk/plugin-entry": "./plugin-sdk/plugin-entry.mjs", - }, - }, - null, - 2, - )}\n`, - ); - await writeFile( - join(hostPackageRoot, "plugin-sdk", "plugin-entry.mjs"), - "export const definePluginEntry = (definition) => definition;\n", - ); - const entrypointSmokeRoot = join(pluginPrefix, "packed-entrypoint-smoke"); - await cp(installedPluginRoot, entrypointSmokeRoot, { recursive: true }); - const runtimeEntry = installedPackageJson.openclaw?.runtimeExtensions?.[0]; - if (runtimeEntry !== "./dist/index.js") { - throw new Error("Installed ClawScan plugin did not declare its built runtime entrypoint."); - } - const entrypointUrl = pathToFileURL(join(entrypointSmokeRoot, runtimeEntry)).href; - run( - "node", - [ - "--input-type=module", - "--eval", - `const plugin = (await import(${JSON.stringify(entrypointUrl)})).default; if (plugin?.id !== "clawscan" || typeof plugin?.register !== "function") throw new Error("packed plugin entrypoint did not load");`, - ], - { cwd: pluginPrefix }, - ); } finally { await rm(prefix, { recursive: true, force: true }); - await rm(pluginPrefix, { recursive: true, force: true }); } } export async function main(argv = process.argv.slice(2)) { const options = parseArgs(argv); - const staged = await stagePackages(options); - let clawscanTarballPath = ""; - let pluginTarballPath = ""; + const staged = await stagePackage(options); + let tarballPath = ""; if (options.pack) { - clawscanTarballPath = await packPackage(options, staged.packageOut); - pluginTarballPath = await packPackage(options, staged.pluginPackageOut); + tarballPath = await packPackage(options, staged.packageOut); } if (options.smoke) { - await smokePackages( - clawscanTarballPath, - pluginTarballPath, - staged.binaryVersion, - staged.packageVersion, - ); + await smokePackage(tarballPath, staged.binaryVersion); } - console.log(`clawscan npm package staged: ${staged.packageOut}`); - console.log(`clawscan plugin npm package staged: ${staged.pluginPackageOut}`); + console.log(`npm package staged: ${staged.packageOut}`); console.log(`package version: ${staged.packageVersion}`); console.log(`binary version: ${staged.binaryVersion}`); - if (clawscanTarballPath) console.log(`clawscan npm tarball: ${clawscanTarballPath}`); - if (pluginTarballPath) console.log(`clawscan plugin npm tarball: ${pluginTarballPath}`); + if (tarballPath) console.log(`npm tarball: ${tarballPath}`); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index f4e5395..a91a38f 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -3,13 +3,11 @@ import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { binaryNameForTarget, - compilePluginTypeScript, - normalizePackageVersion, normalizeBuildDate, + normalizePackageVersion, npmDistTagForVersion, packageTargets, platformKeyForTarget, - preparePluginPackageJson, } from "./build-npm-package.mjs"; describe("normalizePackageVersion", () => { @@ -87,47 +85,3 @@ describe("GitHub release target mapping", () => { ); }); }); - -describe("preparePluginPackageJson", () => { - it("pins the plugin and its binary dependency to the exact release version", () => { - assert.deepEqual( - preparePluginPackageJson( - { - name: "@openclaw/clawscan-plugin", - version: "0.0.0-dev", - private: true, - dependencies: { "@openclaw/clawscan": "0.0.0-dev" }, - openclaw: { - release: { publishToClawHub: false, publishToNpm: false }, - }, - }, - "1.2.3", - ), - { - name: "@openclaw/clawscan-plugin", - version: "1.2.3", - private: true, - files: ["dist/"], - dependencies: { "@openclaw/clawscan": "1.2.3" }, - openclaw: { - release: { publishToClawHub: false, publishToNpm: false }, - runtimeExtensions: ["./dist/index.js"], - }, - }, - ); - }); -}); - -describe("compilePluginTypeScript", () => { - it("removes types and rewrites local TypeScript imports for the installed runtime", () => { - const compiled = compilePluginTypeScript( - 'import type { Host } from "openclaw/plugin-sdk/plugin-entry";\n' + - 'import { register } from "./src/register.ts";\n' + - "const api: Host = register;\n", - ); - - assert.doesNotMatch(compiled, /import type/); - assert.match(compiled, /from "\.\/src\/register\.js"/); - assert.doesNotMatch(compiled, /: Host/); - }); -}); From c2d2cd7d5a1aebf932633e0a9d8347dfad40eb5c Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:00:11 +1000 Subject: [PATCH 19/28] feat(policy): support warning decisions --- README.md | 2 +- cmd/clawscan/main.go | 20 +++++++---- cmd/clawscan/main_test.go | 23 +++++++++++- docs/openclaw-install-policy.md | 33 ++++++++++++----- internal/installpolicy/policy.go | 51 +++++++++++++++++++++++---- internal/installpolicy/policy_test.go | 50 ++++++++++++++++++++------ 6 files changed, 146 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5f4b420..0eeadbf 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ ClawScan turns that approach into a repeatable CLI. It includes a built-in `claw | `clawscan profiles [-v]` | Inspect built-in profiles; `-v` prints the catalog as YAML. | | `clawscan benchmark [list\|]` | Discover or run supported benchmarks through a selected scanner/profile/judge setup. | | `clawscan install [...]` | Install or verify local scanner dependencies where ClawScan has registry-backed install plans. | -| `clawscan openclaw-install-policy` | Act as an external OpenClaw `security.installPolicy.exec` command. Reads the staged install request from stdin and returns allow/block JSON. | +| `clawscan openclaw-install-policy` | Act as an external OpenClaw `security.installPolicy.exec` command. Reads the staged install request from stdin and returns allow/warn/block JSON. | ## OpenClaw install policy diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index da18918..9b3a456 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -188,15 +188,23 @@ func runOpenClawInstallPolicy( }) } if windowsDegraded { - installpolicy.AddFinding(&response, installpolicy.Finding{ - RuleID: "clawscan.windows-static-fallback", - Severity: "warn", - Message: "Docker scanning is unavailable in the native Windows policy path; ClawScan used static analysis only.", - }) + applyWindowsDegradedResponse(&response) } return installpolicy.WriteResponse(output, response) } +func applyWindowsDegradedResponse(response *installpolicy.Response) { + if response.Decision != "block" { + response.Decision = "warn" + response.Reason = "ClawScan used static-only scanning on native Windows; full Docker scanner coverage was unavailable" + } + installpolicy.AddFinding(response, installpolicy.Finding{ + RuleID: "clawscan.windows-static-fallback", + Severity: "warn", + Message: "Docker scanning is unavailable in the native Windows policy path; ClawScan used static analysis only.", + }) +} + func hasProfileSelection(args []string) bool { for _, arg := range args { if arg == "--profile" || strings.HasPrefix(arg, "--profile=") || @@ -776,7 +784,7 @@ Core flags: OpenClaw install policy: openclaw-install-policy Read an OpenClaw security.installPolicy request from stdin and - return its protocol v1 allow/block response on stdout. + return its protocol v1 allow/warn/block response on stdout. Defaults to the composable openclaw-install-policy profile. Benchmark command flags: diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 1a2434f..7d5537d 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/openclaw/clawscan/internal/installpolicy" "github.com/openclaw/clawscan/internal/runner" ) @@ -248,7 +249,9 @@ func TestRunOpenClawInstallPolicyHandlesNPMInstallStagesSeparately(t *testing.T) "plugin":{"pluginId":"demo","contentType":"dependency-tree"} }`, dependencyRoot) dependencyResponse := runInstallPolicyTestRequest(t, staticArgs, dependencyRequest) - if dependencyResponse.Decision != "allow" || len(dependencyResponse.Findings) == 0 { + if dependencyResponse.Decision != "warn" || + strings.TrimSpace(dependencyResponse.Reason) == "" || + len(dependencyResponse.Findings) == 0 { t.Fatalf("dependency response did not expose transitive code to the static gate: %#v", dependencyResponse) } @@ -310,6 +313,24 @@ func TestApplyInstallPolicyPlatformDefaultsUsesVisibleWindowsStaticFallback(t *t } } +func TestApplyWindowsDegradedResponseRequiresConfirmation(t *testing.T) { + response := installpolicy.Response{ProtocolVersion: 1, Decision: "allow"} + applyWindowsDegradedResponse(&response) + if response.Decision != "warn" || strings.TrimSpace(response.Reason) == "" { + t.Fatalf("response = %#v", response) + } + foundFallback := false + for _, finding := range response.Findings { + if finding.RuleID == "clawscan.windows-static-fallback" { + foundFallback = true + break + } + } + if !foundFallback { + t.Fatalf("missing degraded finding: %#v", response.Findings) + } +} + func TestApplyInstallPolicyMetadataDefaultsUsesStaticOnlyForDefaultPreflight(t *testing.T) { opts := runner.Options{ Profile: "openclaw-install-policy", diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md index d6d709a..7ce1534 100644 --- a/docs/openclaw-install-policy.md +++ b/docs/openclaw-install-policy.md @@ -4,10 +4,17 @@ ClawScan can run as OpenClaw's external `security.installPolicy.exec` command. This is an operator-owned boundary. It does not require a ClawScan plugin, plugin activation, or a new install hook. +> [!IMPORTANT] +> Deploy this adapter only with an OpenClaw release whose protocol-v1 install +> policy parser supports `decision: "warn"` and pauses for explicit user +> confirmation. Older allow/block-only hosts intentionally reject `warn` and +> fail closed. No compatible release floor exists until the coordinated +> OpenClaw host change lands. + OpenClaw writes a protocol v1 request to the command's stdin before a supported third-party skill or plugin install/update stage is committed. One install can produce more than one policy call. ClawScan evaluates each staged -`sourcePath` and writes one protocol v1 allow/block response to stdout. +`sourcePath` and writes one protocol v1 allow/warn/block response to stdout. ## Resolve the trusted executable @@ -78,8 +85,9 @@ staged root fail closed. On native Windows, the default profile visibly degrades to `clawscan-static` with the sandbox disabled because the Linux Docker runtime -cannot consume native Windows staging paths. The allow/block response includes -a warning finding for this reduced coverage. Explicit `--scanner` or +cannot consume native Windows staging paths. The response is `warn`, requiring +OpenClaw to obtain explicit confirmation, and includes a finding for this +reduced coverage. Explicit `--scanner` or `--sandbox` arguments remain operator-owned and disable this automatic fallback. To use an operator-owned profile, add explicit arguments: @@ -120,12 +128,19 @@ Successful scans return: {"protocolVersion":1,"decision":"allow"} ``` -Warning gate rules return `decision: "allow"` with bounded findings. Blocking -gate rules return `decision: "block"` with critical findings. Invalid requests, -scanner errors, skipped required scanners, empty results, and unknown gate -verdicts return a valid block response with a fail-closed reason. OpenClaw also -fails closed if the executable cannot start, times out, exits nonzero, or emits -malformed output. +Warning gate rules return `decision: "warn"` with a required reason and +optional bounded findings. OpenClaw owns the confirmation prompt and resumes +the install only after explicit user confirmation. Blocking gate rules return +`decision: "block"` with a required reason and optional critical findings; +blocks are not overridable. Invalid requests, scanner errors, skipped required +scanners, empty results, and unknown gate verdicts return a valid block response +with a fail-closed reason. OpenClaw also fails closed if the executable cannot +start, times out, exits nonzero, emits malformed output, or does not support a +returned protocol decision. + +The policy process never prompts. It does not issue approval tokens, negotiate +capabilities, or maintain install phase IDs. Its only approval signal is the +top-level protocol-v1 decision; OpenClaw owns all acknowledgement state and UI. ## Scope diff --git a/internal/installpolicy/policy.go b/internal/installpolicy/policy.go index af07725..7c68532 100644 --- a/internal/installpolicy/policy.go +++ b/internal/installpolicy/policy.go @@ -65,7 +65,6 @@ type Finding struct { type Response struct { ProtocolVersion int `json:"protocolVersion"` Decision string `json:"decision"` - Code string `json:"code,omitempty"` Reason string `json:"reason,omitempty"` Findings []Finding `json:"findings,omitempty"` } @@ -75,7 +74,7 @@ func AddFinding(response *Response, finding Finding) { response.Findings = append(response.Findings, finding) return } - if response.Decision == "allow" && finding.Severity == "warn" { + if response.Decision != "block" && finding.Severity == "warn" { response.Findings[len(response.Findings)-1] = finding } } @@ -258,7 +257,15 @@ func ResponseFromArtifact(artifact runner.Artifact) Response { return FailureResponse("warn verdict contained a blocking gate rule") } } - return Response{ProtocolVersion: 1, Decision: "allow", Findings: findings} + // Protocol v1 intentionally includes warn in the matching OpenClaw host + // contract. Older allow/block-only hosts reject it and fail closed; the + // policy process does not add capability negotiation or approval state. + return Response{ + ProtocolVersion: 1, + Decision: "warn", + Reason: "ClawScan gate reported warnings for the staged installation", + Findings: findings, + } case "block": hasBlockingFinding := false for _, finding := range findings { @@ -273,7 +280,6 @@ func ResponseFromArtifact(artifact runner.Artifact) Response { return Response{ ProtocolVersion: 1, Decision: "block", - Code: "clawscan_gate_blocked", Reason: "ClawScan gate blocked the staged installation", Findings: findings, } @@ -389,7 +395,6 @@ func FailureResponse(reason string) Response { return Response{ ProtocolVersion: 1, Decision: "block", - Code: "clawscan_scan_failed", Reason: truncateText("ClawScan install policy failed closed: " + reason), } } @@ -409,14 +414,48 @@ func truncateText(value string) string { } func WriteResponse(output io.Writer, response Response) error { - response.Code = truncateText(response.Code) response.Reason = truncateText(response.Reason) for index := range response.Findings { response.Findings[index].RuleID = truncateText(response.Findings[index].RuleID) response.Findings[index].Message = truncateText(response.Findings[index].Message) response.Findings[index].Evidence = truncateText(response.Findings[index].Evidence) } + if err := validateResponse(response); err != nil { + return err + } encoder := json.NewEncoder(output) encoder.SetEscapeHTML(false) return encoder.Encode(response) } + +func validateResponse(response Response) error { + if response.ProtocolVersion != 1 { + return errors.New("policy response protocolVersion must be 1") + } + switch response.Decision { + case "allow": + case "warn", "block": + if strings.TrimSpace(response.Reason) == "" { + return fmt.Errorf( + `policy response decision %q requires a non-empty reason`, + response.Decision, + ) + } + default: + return errors.New(`policy response decision must be "allow", "warn", or "block"`) + } + if len(response.Findings) > maxFindings { + return fmt.Errorf("policy response exceeds %d findings", maxFindings) + } + for _, finding := range response.Findings { + if strings.TrimSpace(finding.RuleID) == "" || strings.TrimSpace(finding.Message) == "" { + return errors.New("policy response findings require non-empty ruleId and message") + } + switch finding.Severity { + case "info", "warn", "critical": + default: + return errors.New("policy response finding severity is not supported") + } + } + return nil +} diff --git a/internal/installpolicy/policy_test.go b/internal/installpolicy/policy_test.go index 5c1836d..87b8fe8 100644 --- a/internal/installpolicy/policy_test.go +++ b/internal/installpolicy/policy_test.go @@ -119,7 +119,7 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { name string artifact runner.Artifact decision string - code string + reason bool findings int }{ { @@ -148,7 +148,8 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { Action: "warn", }}, }, - decision: "allow", + decision: "warn", + reason: true, findings: 1, }, { @@ -167,7 +168,7 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { }}, }, decision: "block", - code: "clawscan_gate_blocked", + reason: true, findings: 1, }, { @@ -179,7 +180,7 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { }, }, decision: "block", - code: "clawscan_scan_failed", + reason: true, }, { name: "scanner failure", @@ -188,7 +189,7 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { Scanners: map[string]runner.ScannerResult{"static": {Status: "failed", Error: "boom"}}, }, decision: "block", - code: "clawscan_scan_failed", + reason: true, }, { name: "scanner skipped", @@ -197,14 +198,15 @@ func TestResponseFromArtifactMapsGateAndScannerState(t *testing.T) { Scanners: map[string]runner.ScannerResult{"static": {Status: "skipped"}}, }, decision: "block", - code: "clawscan_scan_failed", + reason: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { response := ResponseFromArtifact(test.artifact) - if response.Decision != test.decision || response.Code != test.code { + if response.Decision != test.decision || + (strings.TrimSpace(response.Reason) != "") != test.reason { t.Fatalf("response = %#v", response) } if len(response.Findings) != test.findings { @@ -227,16 +229,18 @@ func TestFailureResponseAndWriteResponseUsePolicyProtocol(t *testing.T) { } if decoded["protocolVersion"] != float64(1) || decoded["decision"] != "block" || - decoded["code"] != "clawscan_scan_failed" { + strings.TrimSpace(decoded["reason"].(string)) == "" { t.Fatalf("response = %#v", decoded) } + if _, exists := decoded["code"]; exists { + t.Fatalf("response contains non-contract code field: %#v", decoded) + } } func TestWriteResponseSanitizesControlCharactersInAllDiagnosticText(t *testing.T) { response := Response{ ProtocolVersion: 1, Decision: "block", - Code: "scan\x1b[31m_failed", Reason: "first line\nforged line\tend", Findings: []Finding{{ RuleID: "scanner.\x00rule", @@ -254,7 +258,6 @@ func TestWriteResponseSanitizesControlCharactersInAllDiagnosticText(t *testing.T t.Fatal(err) } for name, value := range map[string]string{ - "code": decoded.Code, "reason": decoded.Reason, "ruleId": decoded.Findings[0].RuleID, "message": decoded.Findings[0].Message, @@ -271,6 +274,33 @@ func TestWriteResponseSanitizesControlCharactersInAllDiagnosticText(t *testing.T } } +func TestWriteResponseRequiresReasonsForWarnAndBlock(t *testing.T) { + for _, decision := range []string{"warn", "block"} { + t.Run(decision, func(t *testing.T) { + var output bytes.Buffer + err := WriteResponse(&output, Response{ + ProtocolVersion: 1, + Decision: decision, + Reason: "\n\t", + }) + if err == nil || !strings.Contains(err.Error(), "requires a non-empty reason") { + t.Fatalf("error = %v", err) + } + if output.Len() != 0 { + t.Fatalf("invalid response reached stdout: %q", output.String()) + } + }) + } +} + +func TestWriteResponseRejectsUnsupportedDecision(t *testing.T) { + var output bytes.Buffer + err := WriteResponse(&output, Response{ProtocolVersion: 1, Decision: "confirm"}) + if err == nil || !strings.Contains(err.Error(), `"allow", "warn", or "block"`) { + t.Fatalf("error = %v", err) + } +} + func TestResponseFromArtifactBoundsUntrustedFindingOutput(t *testing.T) { rules := make([]runner.FiredGateRule, maxFindings) for index := range rules { From 324dcff3dd5c3618c7daa05bbe4a8fa61a3e7ca7 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:23:46 +1000 Subject: [PATCH 20/28] docs(policy): explain sandbox path sharing --- docs/openclaw-install-policy.md | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md index 7ce1534..6416a23 100644 --- a/docs/openclaw-install-policy.md +++ b/docs/openclaw-install-policy.md @@ -66,6 +66,81 @@ The default `openclaw-install-policy` profile composes SkillSpector and command-backed scanners in Docker by default. `PATH` lets it locate Docker; `DOCKER_HOST` is only needed when the local Docker setup uses it. +## Understand the sandbox boundary + +There are two separate execution boundaries: + +1. OpenClaw runs `security.installPolicy.exec` as a trusted local child of the + Gateway/install process. The normal OpenClaw agent tool sandbox does not run + or isolate this command. +2. ClawScan runs command-backed scanners such as SkillSpector in its own Docker + sandbox. The built-in `clawscan-static` scanner runs inside the trusted + ClawScan policy process. + +OpenClaw downloads, clones, uploads, or extracts a candidate into a temporary +staging location before install commit. It sends the absolute staged +`sourcePath`, its `file` or `directory` kind, and the host-declared `skill` or +`plugin` target type to ClawScan over stdin. ClawScan uses that target type +directly instead of trying to rediscover it from a manifest. + +For a command-backed scanner, ClawScan automatically bind-mounts every existing +absolute path passed to the scanner. The staged target is mounted read-only at +the same absolute path inside the container; the scanner's temporary result +directory is mounted writable. An invocation is conceptually equivalent to: + +```sh +docker run --rm \ + --mount type=bind,source=/tmp/openclaw-install/package,target=/tmp/openclaw-install/package,readonly \ + --mount type=bind,source=/tmp/clawscan-results,target=/tmp/clawscan-results \ + ghcr.io/openclaw/clawscan-runtime:latest \ + skillspector scan /tmp/openclaw-install/package \ + --format json \ + --output /tmp/clawscan-results/report.json +``` + +Operators do not need to add a `--sandbox-mount` for `sourcePath`. That option +is only for extra operator-owned paths required by a custom scanner or judge. + +### Containerized OpenClaw Gateway + +When the OpenClaw Gateway itself runs in a container, the policy executable +must exist inside that container at the configured absolute `command` path. +The default nested scanner sandbox additionally requires the Docker CLI and +access to a Docker daemon. + +If the Gateway container uses the host Docker socket, a staged path that exists +only in the Gateway container cannot be bind-mounted into the scanner +container. Docker resolves bind-mount sources in the daemon host's filesystem, +not the calling container's filesystem. The same rule applies to ClawScan's +writable temporary result directories. + +Use one temporary root that is bind-mounted from the Docker host into the +Gateway at the same absolute path, set the Gateway's `TMPDIR` to that root, and +include `TMPDIR` in the policy command's `passEnv`. OpenClaw staging paths and +ClawScan result paths will then both be visible to the host Docker daemon: + +```json5 +passEnv: ["PATH", "DOCKER_HOST", "TMPDIR"] +``` + +For example, mount `/var/lib/openclaw-install-tmp` into the Gateway at +`/var/lib/openclaw-install-tmp` and start the Gateway with +`TMPDIR=/var/lib/openclaw-install-tmp`. Do not use a container-only `/tmp` for +either staging or ClawScan results in this nested-Docker topology. + +Alternatively, treat the outer Gateway container as the isolation boundary, +install every selected command-backed scanner inside it, and explicitly disable +ClawScan's nested Docker sandbox: + +```json5 +args: ["openclaw-install-policy", "--sandbox", "off"] +``` + +This alternative runs scanner commands directly inside the Gateway container. +Use it only when that outer environment is intentionally isolated and +disposable. Do not disable the sandbox merely to work around a missing Docker +daemon or mismatched staging paths. + For npm plugin installs, OpenClaw calls the policy before mutation with an `npm-package-metadata.json` file, then calls it again for the resolved package and installed dependency tree. ClawScan narrowly recognizes the metadata call From bd0a16e606f9f556779fd0f06572d9d3208534f7 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:57:47 +1000 Subject: [PATCH 21/28] fix(profiles): preserve ClawHub gate behavior --- cmd/clawscan/main_test.go | 10 +-- internal/profiles/clawhub/clawscan.yml | 46 +--------- internal/profiles/resolver_test.go | 113 +------------------------ 3 files changed, 12 insertions(+), 157 deletions(-) diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 635aa27..f1f1491 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -221,11 +221,8 @@ profiles: "profiles:", "clawhub:", "clawhub-aig:", - "- id: skillspector", - "id: do-not-install", - "- risk_assessment.recommendation", - "equals: DO_NOT_INSTALL", - "normalize: identifier", + "- skillspector", + "- clawscan-static", "- aig", } { if !strings.Contains(stdout, want) { @@ -238,6 +235,9 @@ profiles: if strings.Contains(stdout, "local-review:") { t.Fatalf("verbose profiles output should not include project profile:\n%s", stdout) } + if strings.Contains(stdout, "gate:") { + t.Fatalf("embedded profiles should preserve their existing gate-free contract:\n%s", stdout) + } } func TestRunCommandBenchmarkListPrintsCatalogTable(t *testing.T) { diff --git a/internal/profiles/clawhub/clawscan.yml b/internal/profiles/clawhub/clawscan.yml index 14155b0..23f1d07 100644 --- a/internal/profiles/clawhub/clawscan.yml +++ b/internal/profiles/clawhub/clawscan.yml @@ -3,46 +3,8 @@ version: 1 profiles: clawhub: scanners: - - id: skillspector - gate: - rules: &skillspector-gate-rules - - id: do-not-install - path: - - risk_recommendation|riskRecommendation|recommendation - - risk_assessment.recommendation|risk_recommendation|riskRecommendation - - riskAssessment.recommendation|risk_recommendation|riskRecommendation - equals: DO_NOT_INSTALL - normalize: identifier - action: block - - id: critical-finding - path: - - filtered_findings[].severity|risk_severity|level - - filteredFindings[].severity|risk_severity|level - - findings[].severity|risk_severity|level - - issues[].severity|risk_severity|level - - vulnerabilities[].severity|risk_severity|level - equals: CRITICAL - normalize: identifier - fallback: root - action: block - - id: high-finding - path: - - filtered_findings[].severity|risk_severity|level - - filteredFindings[].severity|risk_severity|level - - findings[].severity|risk_severity|level - - issues[].severity|risk_severity|level - - vulnerabilities[].severity|risk_severity|level - equals: HIGH - normalize: identifier - fallback: root - action: warn - - id: clawscan-static - gate: - rules: - - id: any-finding - path: findings[] - exists: true - action: warn + - skillspector + - clawscan-static sandbox: env: - OPENAI_API_KEY @@ -67,9 +29,7 @@ profiles: - < {{ prompt:prompt.md }} clawhub-aig: scanners: - - id: skillspector - gate: - rules: *skillspector-gate-rules + - skillspector - aig sandbox: env: diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index bd66781..a492ceb 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -32,11 +32,8 @@ func TestResolveArgsUsesEmbeddedClawHubProfile(t *testing.T) { if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static" { t.Fatalf("scanners = %q", got) } - if got := len(opts.GateRules["skillspector"].JSONRules); got != 3 { - t.Fatalf("skillspector JSON gate rules = %#v", opts.GateRules["skillspector"].JSONRules) - } - if got := len(opts.GateRules["clawscan-static"].JSONRules); got != 1 { - t.Fatalf("static JSON gate rules = %#v", opts.GateRules["clawscan-static"].JSONRules) + if len(opts.GateRules) != 0 { + t.Fatalf("embedded clawhub profile changed its existing gate contract: %#v", opts.GateRules) } if opts.Judge == nil { t.Fatal("expected embedded clawhub judge") @@ -85,8 +82,8 @@ func TestResolveArgsUsesEmbeddedClawHubAIGCandidateProfile(t *testing.T) { if got := strings.Join(candidate.Scanners, ","); got != "skillspector,aig" { t.Fatalf("scanners = %q", got) } - if got := len(candidate.GateRules["skillspector"].JSONRules); got != 3 { - t.Fatalf("skillspector JSON gate rules = %#v", candidate.GateRules["skillspector"].JSONRules) + if len(candidate.GateRules) != 0 { + t.Fatalf("embedded clawhub-aig profile changed its existing gate contract: %#v", candidate.GateRules) } if candidate.Judge == nil || clawhub.Judge == nil { t.Fatal("missing embedded ClawHub judge") @@ -983,108 +980,6 @@ profiles: } } -func TestEmbeddedClawHubGateCoversSupportedSkillSpectorShapes(t *testing.T) { - tests := []struct { - name string - raw json.RawMessage - want string - path string - }{ - { - name: "top-level recommendation", - raw: json.RawMessage(`{"recommendation":"do-not-install","issues":[]}`), - want: "block", - path: "risk_recommendation|riskRecommendation|recommendation", - }, - { - name: "null recommendation alias falls through", - raw: json.RawMessage(`{"risk_recommendation":null,"recommendation":"DO_NOT_INSTALL","issues":[]}`), - want: "block", - path: "risk_recommendation|riskRecommendation|recommendation", - }, - { - name: "nested recommendation behind empty top-level alias", - raw: json.RawMessage(`{"recommendation":"","risk_assessment":{"recommendation":"DO_NOT_INSTALL"},"issues":[]}`), - want: "block", - path: "risk_assessment.recommendation|risk_recommendation|riskRecommendation", - }, - { - name: "risk recommendation has precedence", - raw: json.RawMessage(`{"recommendation":"SAFE","risk_recommendation":"DO_NOT_INSTALL","issues":[]}`), - want: "block", - path: "risk_recommendation|riskRecommendation|recommendation", - }, - { - name: "empty preferred recommendation group falls back to nested", - raw: json.RawMessage(`{"risk_recommendation":"","recommendation":"SAFE","risk_assessment":{"recommendation":"DO_NOT_INSTALL"},"issues":[]}`), - want: "block", - path: "risk_assessment.recommendation|risk_recommendation|riskRecommendation", - }, - { - name: "camel-case report", - raw: json.RawMessage(`{"riskAssessment":{"recommendation":"CAUTION"},"filteredFindings":[{"severity":"CRITICAL"}]}`), - want: "block", - path: "filteredFindings[].severity|risk_severity|level", - }, - { - name: "alternate issue fields", - raw: json.RawMessage(`{"findings":[{"level":"high"}]}`), - want: "warn", - path: "findings[].severity|risk_severity|level", - }, - { - name: "empty filtered findings override raw findings", - raw: json.RawMessage(`{"filtered_findings":[],"findings":[{"severity":"CRITICAL"}]}`), - want: "pass", - }, - { - name: "null filtered findings fall through to raw findings", - raw: json.RawMessage(`{"filtered_findings":null,"findings":[{"severity":"CRITICAL"}]}`), - want: "block", - path: "findings[].severity|risk_severity|level", - }, - { - name: "nonmatching filtered findings override raw findings", - raw: json.RawMessage(`{"filtered_findings":[{"severity":"LOW"}],"findings":[{"severity":"CRITICAL"}]}`), - want: "pass", - }, - { - name: "finding field aliases resolve per item", - raw: json.RawMessage(`{"filtered_findings":[{"severity":"LOW"},{"risk_severity":"CRITICAL"}]}`), - want: "block", - path: "filtered_findings[].severity|risk_severity|level", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - opts, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--sandbox", "off"}, t.TempDir()) - if err != nil { - t.Fatal(err) - } - opts.Judge = nil - artifact, err := runner.Run(opts, runner.RunContext{ - Env: map[string]string{}, - ScannerRunner: profileScannerResultRunner{results: map[string]runner.ScannerResult{ - "skillspector": {Status: "completed", Raw: test.raw}, - "clawscan-static": {Status: "completed", Raw: json.RawMessage(`{"findings":[]}`)}, - }}, - }) - if err != nil { - t.Fatal(err) - } - if artifact.Gate != test.want { - t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) - } - if test.path == "" && len(artifact.GateRules) != 0 { - t.Fatalf("gate rules = %#v", artifact.GateRules) - } - if test.path != "" && (len(artifact.GateRules) != 1 || artifact.GateRules[0].Path != test.path) { - t.Fatalf("gate rules = %#v", artifact.GateRules) - } - }) - } -} - func TestCommandScannerDeclarativeJSONRuleGatesUnchangedOutput(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "skill") From 3c64e689ae761775c05ec78d83af0dbf3cb006e3 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:05:33 +1000 Subject: [PATCH 22/28] fix(gate): align alias and schema validation --- internal/runner/runner.go | 7 +++++-- internal/runner/runner_test.go | 22 ++++++++++++++++++++++ schemas/clawscan.schema.json | 4 ++-- schemas/clawscan.schema_test.go | 12 ++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index a0185ff..6d6dd2f 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -2410,10 +2410,13 @@ func jsonGatePathValues(document any, path string) ([]any, bool, bool, bool) { } if array { child, ok := object[keys[0]] - if !ok || child == nil { + if !ok { continue } rootKeyPresent = true + if child == nil { + continue + } items, ok := child.([]any) if ok { present = true @@ -2424,7 +2427,7 @@ func jsonGatePathValues(document any, path string) ([]any, bool, bool, bool) { for _, key := range keys { child, ok := object[key] - if !ok || child == nil { + if !ok { continue } rootKeyPresent = true diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index abd6974..5e88c3b 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1845,6 +1845,7 @@ func TestRunDeclarativeJSONRuleCanPreferAnExistingRoot(t *testing.T) { for _, raw := range []json.RawMessage{ json.RawMessage(`{"preferred":[{}],"legacy":[{"severity":"critical"}]}`), json.RawMessage(`{"preferred":{},"legacy":[{"severity":"critical"}]}`), + json.RawMessage(`{"preferred":null,"legacy":[{"severity":"critical"}]}`), } { artifact, err := Run(Options{ Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, @@ -1864,6 +1865,27 @@ func TestRunDeclarativeJSONRuleCanPreferAnExistingRoot(t *testing.T) { } } +func TestRunDeclarativeJSONRulePreservesExplicitNullFieldAlias(t *testing.T) { + artifact, err := Run(Options{ + Target: t.TempDir(), Scanners: []string{"skillspector"}, Sandbox: SandboxOptions{Mode: SandboxModeOff}, + GateRules: map[string]ScannerGatePolicy{"skillspector": {JSONRules: []JSONGateRule{{ + ID: "critical-finding", Paths: []string{"preferred.severity|level", "legacy.severity"}, + Equals: json.RawMessage(`"critical"`), Action: "block", + }}}}, + }, RunContext{Env: map[string]string{}, ScannerRunner: &gateScannerRunner{results: map[string]ScannerResult{ + "skillspector": { + Status: "completed", + Raw: json.RawMessage(`{"preferred":{"severity":null,"level":"low"},"legacy":{"severity":"critical"}}`), + }, + }}}) + if err != nil { + t.Fatal(err) + } + if artifact.Gate != "block" || len(artifact.GateRules) != 1 || artifact.GateRules[0].Path != "legacy.severity" { + t.Fatalf("gate = %q, rules = %#v", artifact.Gate, artifact.GateRules) + } +} + func TestRunDeclarativeJSONRuleFallsBackFromEmptyScalarValues(t *testing.T) { for _, raw := range []json.RawMessage{ json.RawMessage(`{"preferred":"","legacy":"critical"}`), diff --git a/schemas/clawscan.schema.json b/schemas/clawscan.schema.json index 19914e8..7ba533c 100644 --- a/schemas/clawscan.schema.json +++ b/schemas/clawscan.schema.json @@ -215,7 +215,7 @@ "oneOf": [ { "type": "string", - "minLength": 1 + "pattern": "^(?:[^.\\[\\]|]+(?:\\|[^.\\[\\]|]+)*|[^.\\[\\]|]+\\[\\])(?:\\.(?:[^.\\[\\]|]+(?:\\|[^.\\[\\]|]+)*|[^.\\[\\]|]+\\[\\]))*$" }, { "type": "array", @@ -223,7 +223,7 @@ "uniqueItems": true, "items": { "type": "string", - "minLength": 1 + "pattern": "^(?:[^.\\[\\]|]+(?:\\|[^.\\[\\]|]+)*|[^.\\[\\]|]+\\[\\])(?:\\.(?:[^.\\[\\]|]+(?:\\|[^.\\[\\]|]+)*|[^.\\[\\]|]+\\[\\]))*$" } } ] diff --git a/schemas/clawscan.schema_test.go b/schemas/clawscan.schema_test.go index 4f783f4..1393fd1 100644 --- a/schemas/clawscan.schema_test.go +++ b/schemas/clawscan.schema_test.go @@ -125,6 +125,18 @@ func TestClawScanSchemaRejectsInvalidGateRules(t *testing.T) { path: [] exists: true action: block +`, + "indexed scalar path": ` + - id: risky + path: "findings[0].severity" + equals: critical + action: block +`, + "indexed path in list": ` + - id: risky + path: [result.risk, "findings[0].severity"] + equals: critical + action: block `, } From 90db8966e1b975c096740c9f94357ac258d5240b Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:13:39 +1000 Subject: [PATCH 23/28] test(profiles): cover opt-in install policy gate --- cmd/clawscan/main_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index bd9c332..889af76 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -540,9 +540,11 @@ profiles: "profiles:", "clawhub:", "clawhub-aig:", + "openclaw-install-policy:", "- skillspector", "- clawscan-static", "- aig", + "gate:", } { if !strings.Contains(stdout, want) { t.Fatalf("verbose profiles output missing %q:\n%s", want, stdout) @@ -554,9 +556,6 @@ profiles: if strings.Contains(stdout, "local-review:") { t.Fatalf("verbose profiles output should not include project profile:\n%s", stdout) } - if strings.Contains(stdout, "gate:") { - t.Fatalf("embedded profiles should preserve their existing gate-free contract:\n%s", stdout) - } } func TestRunCommandBenchmarkListPrintsCatalogTable(t *testing.T) { From 3c457a94d09c3e683b587fe6bf7de7f0cd5a24dd Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:28:07 +1000 Subject: [PATCH 24/28] fix(policy): scan dependency symlink targets safely --- docs/openclaw-install-policy.md | 3 +- internal/installpolicy/stages.go | 138 +++++++++++++++++++------- internal/installpolicy/stages_test.go | 58 ++++++++++- 3 files changed, 159 insertions(+), 40 deletions(-) diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md index 6416a23..7845edb 100644 --- a/docs/openclaw-install-policy.md +++ b/docs/openclaw-install-policy.md @@ -156,7 +156,8 @@ phase with no installed runtime dependencies returns an explicit allow/info response because the package itself was already scanned in the package phase. For managed npm roots, the dependency view omits only OpenClaw's exact host-validated `node_modules/openclaw` peer symlink; other links escaping the -staged root fail closed. +staged root fail closed. Safe links within the staged root are dereferenced +into stable copies so scanners inspect the code the installed package will use. On native Windows, the default profile visibly degrades to `clawscan-static` with the sandbox disabled because the Linux Docker runtime diff --git a/internal/installpolicy/stages.go b/internal/installpolicy/stages.go index bc1248c..2f87996 100644 --- a/internal/installpolicy/stages.go +++ b/internal/installpolicy/stages.go @@ -132,7 +132,7 @@ func PrepareDependencyTreeScanTarget( budget := dependencyCopyBudget{} for index, packageDir := range packageDirs { destination := filepath.Join(scanRoot, fmt.Sprintf("%05d", index+1)) - if err := copyDependencyPackage(packageDir, destination, &budget); err != nil { + if err := copyDependencyPackage(root, packageDir, destination, &budget); err != nil { cleanup() return "", nil, false, err } @@ -274,21 +274,34 @@ func addDependencyPackage( } func copyDependencyPackage( + dependencyRoot string, source string, destination string, budget *dependencyCopyBudget, ) error { - return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return fmt.Errorf("read installed dependency %s: %w", path, walkErr) - } - relative, err := filepath.Rel(source, path) - if err != nil { - return err - } - if relative == "." { - return os.MkdirAll(destination, 0o755) - } + return copyDependencyEntry( + dependencyRoot, + source, + destination, + budget, + map[string]bool{}, + false, + ) +} + +func copyDependencyEntry( + dependencyRoot string, + source string, + destination string, + budget *dependencyCopyBudget, + directoryAncestors map[string]bool, + countEntry bool, +) error { + info, err := os.Lstat(source) + if err != nil { + return fmt.Errorf("inspect installed dependency entry %s: %w", source, err) + } + if countEntry { budget.entries++ if budget.entries > maxDependencyEntries { return fmt.Errorf( @@ -296,38 +309,89 @@ func copyDependencyPackage( maxDependencyEntries, ) } - if entry.IsDir() { - if entry.Name() == "node_modules" || entry.Name() == ".git" { - return filepath.SkipDir - } - return os.MkdirAll(filepath.Join(destination, relative), 0o755) + } + + if info.Mode()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(source) + if err != nil { + return fmt.Errorf("resolve installed dependency symlink %s: %w", source, err) } - if entry.Type()&os.ModeSymlink != 0 { - return nil + if !pathWithin(dependencyRoot, resolved) { + return fmt.Errorf("installed dependency symlink escapes dependency-tree root: %s", source) } - info, err := entry.Info() + return copyDependencyEntry( + dependencyRoot, + resolved, + destination, + budget, + directoryAncestors, + false, + ) + } + + if info.IsDir() { + resolved, err := filepath.EvalSymlinks(source) if err != nil { - return err + return fmt.Errorf("resolve installed dependency directory %s: %w", source, err) } - if !info.Mode().IsRegular() { - return fmt.Errorf("installed dependency contains a special file: %s", path) + if !pathWithin(dependencyRoot, resolved) { + return fmt.Errorf("installed dependency directory escapes dependency-tree root: %s", source) } - if info.Size() > maxDependencyFileBytes { - return fmt.Errorf( - "dependency file exceeds %d bytes: %s", - maxDependencyFileBytes, - path, - ) + if directoryAncestors[resolved] { + return fmt.Errorf("installed dependency contains a symlink cycle: %s", source) } - if budget.totalBytes > maxDependencyTotalBytes-info.Size() { - return fmt.Errorf( - "dependency-tree scan view exceeds %d total bytes", - maxDependencyTotalBytes, - ) + directoryAncestors[resolved] = true + defer delete(directoryAncestors, resolved) + if err := os.MkdirAll(destination, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(resolved) + if err != nil { + return fmt.Errorf("read installed dependency %s: %w", resolved, err) + } + for _, entry := range entries { + if entry.Name() == "node_modules" || entry.Name() == ".git" { + budget.entries++ + if budget.entries > maxDependencyEntries { + return fmt.Errorf( + "dependency-tree scan view exceeds %d filesystem entries", + maxDependencyEntries, + ) + } + continue + } + if err := copyDependencyEntry( + dependencyRoot, + filepath.Join(resolved, entry.Name()), + filepath.Join(destination, entry.Name()), + budget, + directoryAncestors, + true, + ); err != nil { + return err + } } - budget.totalBytes += info.Size() - return copyRegularFile(path, filepath.Join(destination, relative), info.Size()) - }) + return nil + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("installed dependency contains a special file: %s", source) + } + if info.Size() > maxDependencyFileBytes { + return fmt.Errorf( + "dependency file exceeds %d bytes: %s", + maxDependencyFileBytes, + source, + ) + } + if budget.totalBytes > maxDependencyTotalBytes-info.Size() { + return fmt.Errorf( + "dependency-tree scan view exceeds %d total bytes", + maxDependencyTotalBytes, + ) + } + budget.totalBytes += info.Size() + return copyRegularFile(source, destination, info.Size()) } func copyRegularFile(source string, destination string, expectedBytes int64) error { diff --git a/internal/installpolicy/stages_test.go b/internal/installpolicy/stages_test.go index 8fe941e..21164ea 100644 --- a/internal/installpolicy/stages_test.go +++ b/internal/installpolicy/stages_test.go @@ -197,18 +197,71 @@ func TestPrepareDependencyTreeScanTargetSkipsOnlyTrustedOpenClawPeerEscape(t *te } } +func TestPrepareDependencyTreeScanTargetRejectsEscapingPackageSymlink(t *testing.T) { + root := t.TempDir() + packageDir := filepath.Join(root, "node_modules", "demo") + writeStageTestFile(t, filepath.Join(packageDir, "package.json"), `{"name":"demo"}`) + + outside := filepath.Join(t.TempDir(), "payload.js") + writeStageTestFile(t, outside, "hidden runtime code") + link := filepath.Join(packageDir, "index.js") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("file symlinks unavailable: %v", err) + } + + if _, _, _, err := PrepareDependencyTreeScanTarget(root, false); err == nil || + !strings.Contains(err.Error(), "symlink escapes dependency-tree root") { + t.Fatalf("escaping package symlink error = %v", err) + } +} + +func TestPrepareDependencyTreeScanTargetCopiesSafePackageSymlinkTargets(t *testing.T) { + root := t.TempDir() + packageDir := filepath.Join(root, "node_modules", "demo") + writeStageTestFile(t, filepath.Join(packageDir, "package.json"), `{"name":"demo"}`) + target := filepath.Join(root, "shared", "runtime.js") + writeStageTestFile(t, target, "visible runtime code") + link := filepath.Join(packageDir, "index.js") + if err := os.Symlink(target, link); err != nil { + t.Skipf("file symlinks unavailable: %v", err) + } + + scanRoot, cleanup, empty, err := PrepareDependencyTreeScanTarget(root, false) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if empty { + t.Fatal("dependency scan view unexpectedly reported no packages") + } + data, err := os.ReadFile(filepath.Join(scanRoot, "00001", "index.js")) + if err != nil { + t.Fatal(err) + } + if string(data) != "visible runtime code" { + t.Fatalf("copied symlink target = %q", data) + } + info, err := os.Lstat(filepath.Join(scanRoot, "00001", "index.js")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatal("scan view retained a symlink instead of a stable file copy") + } +} + func TestCopyDependencyPackageEnforcesEntryAndByteBudgets(t *testing.T) { source := t.TempDir() writeStageTestFile(t, filepath.Join(source, "package.json"), `{"name":"demo"}`) entryBudget := dependencyCopyBudget{entries: maxDependencyEntries} - err := copyDependencyPackage(source, filepath.Join(t.TempDir(), "entries"), &entryBudget) + err := copyDependencyPackage(source, source, filepath.Join(t.TempDir(), "entries"), &entryBudget) if err == nil || !strings.Contains(err.Error(), "filesystem entries") { t.Fatalf("entry budget error = %v", err) } byteBudget := dependencyCopyBudget{totalBytes: maxDependencyTotalBytes} - err = copyDependencyPackage(source, filepath.Join(t.TempDir(), "bytes"), &byteBudget) + err = copyDependencyPackage(source, source, filepath.Join(t.TempDir(), "bytes"), &byteBudget) if err == nil || !strings.Contains(err.Error(), "total bytes") { t.Fatalf("byte budget error = %v", err) } @@ -227,6 +280,7 @@ func TestCopyDependencyPackageEnforcesEntryAndByteBudgets(t *testing.T) { t.Fatal(err) } err = copyDependencyPackage( + largeSource, largeSource, filepath.Join(t.TempDir(), "large"), &dependencyCopyBudget{}, From cecb30436906cd75db65870a267952ea824749bb Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:40:41 +1000 Subject: [PATCH 25/28] fix(policy): fail closed on malformed npm preflight --- cmd/clawscan/main.go | 2 +- cmd/clawscan/main_test.go | 7 +++ docs/openclaw-install-policy.md | 24 ++++---- internal/installpolicy/policy.go | 23 ++------ internal/installpolicy/stages.go | 24 +++++++- internal/installpolicy/stages_test.go | 80 ++++++++++++++++++--------- 6 files changed, 100 insertions(+), 60 deletions(-) diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 9b3a456..5695435 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -141,7 +141,7 @@ func runOpenClawInstallPolicy( opts.TargetKind = request.TargetType opts.JSON = false opts.OutputPath = "" - metadataPreflight := request.IsNPMMetadataPreflight() + metadataPreflight := request.IsNPMMetadataStage() if metadataPreflight { if err := installpolicy.ValidateNPMMetadataPreflight(request); err != nil { return failClosed(err) diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 889af76..424b395 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -211,6 +211,13 @@ func TestRunOpenClawInstallPolicyHandlesNPMInstallStagesSeparately(t *testing.T) !hasInstallPolicyFinding(metadataResponse.Findings, "clawscan.npm-metadata-preflight") { t.Fatalf("metadata response = %#v", metadataResponse) } + malformedMetadataRequest := strings.Replace(metadataRequest, `"mutable":false`, `"mutable":true`, 1) + malformedMetadataResponse := runInstallPolicyTestRequest(t, nil, malformedMetadataRequest) + if malformedMetadataResponse.Decision != "block" || + !strings.Contains(malformedMetadataResponse.Reason, "source provenance is inconsistent") || + hasInstallPolicyFinding(malformedMetadataResponse.Findings, "clawscan.npm-metadata-preflight") { + t.Fatalf("malformed metadata response = %#v", malformedMetadataResponse) + } packageDir := filepath.Join(dir, "resolved-package") writeFile(t, filepath.Join(packageDir, "package.json"), `{"name":"@acme/demo"}`) diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md index 7845edb..3156662 100644 --- a/docs/openclaw-install-policy.md +++ b/docs/openclaw-install-policy.md @@ -143,17 +143,19 @@ daemon or mismatched staging paths. For npm plugin installs, OpenClaw calls the policy before mutation with an `npm-package-metadata.json` file, then calls it again for the resolved package -and installed dependency tree. ClawScan narrowly recognizes the metadata call -from its complete host tuple: plugin/npm request and origin, immutable network -npm source, package content role, file path kind, matching package names, and -the exact metadata filename. It validates that provenance and uses the built-in -static scanner without Docker for this lightweight phase. It does not present -that result as a scan of plugin code. The later package and dependency-tree -calls keep the full profile. Dependency packages are exposed in a dedicated -scan view so normal `node_modules` exclusions cannot hide their code. Local -`plugin-file` requests never match the metadata shortcut. A dependency-tree -phase with no installed runtime dependencies returns an explicit allow/info -response because the package itself was already scanned in the package phase. +and installed dependency tree. ClawScan identifies the metadata stage from its +plugin/npm file-stage shape, then validates the complete host tuple before +allowing the lightweight path: npm origin, immutable network npm source, +package content role, matching package names, and the exact metadata filename. +A malformed metadata-stage tuple fails closed instead of falling through to an +ordinary file scan. Valid metadata uses the built-in static scanner without +Docker and is not presented as a scan of plugin code. The later package and +dependency-tree calls keep the full profile. Dependency packages are exposed +in a dedicated scan view so normal `node_modules` exclusions cannot hide their +code. Local `plugin-file` requests never match the metadata shortcut. A +dependency-tree phase with no installed runtime dependencies returns an +explicit allow/info response because the package itself was already scanned in +the package phase. For managed npm roots, the dependency view omits only OpenClaw's exact host-validated `node_modules/openclaw` peer symlink; other links escaping the staged root fail closed. Safe links within the staged root are dereferenced diff --git a/internal/installpolicy/policy.go b/internal/installpolicy/policy.go index 7c68532..073ccc2 100644 --- a/internal/installpolicy/policy.go +++ b/internal/installpolicy/policy.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "path/filepath" "sort" "strings" "unicode" @@ -163,24 +162,10 @@ func validPluginRequestKind(kind string) bool { } } -func (request Request) IsNPMMetadataPreflight() bool { - if request.TargetType != "plugin" || - request.Request.Kind != "plugin-npm" || - request.SourcePathKind != "file" || - filepath.Base(filepath.Clean(request.SourcePath)) != "npm-package-metadata.json" || - request.Plugin == nil || - request.Plugin.ContentType != "package" || - strings.TrimSpace(request.Plugin.PackageName) == "" || - request.Source == nil || - request.Source.Kind != "npm" || - request.Source.Mutable || - !request.Source.Network || - (request.Source.Authority != "official" && request.Source.Authority != "third-party") { - return false - } - originType, _ := request.Origin["type"].(string) - originPackageName, _ := request.Origin["packageName"].(string) - return originType == "plugin-npm" && originPackageName == request.Plugin.PackageName +func (request Request) IsNPMMetadataStage() bool { + return request.TargetType == "plugin" && + request.Request.Kind == "plugin-npm" && + request.SourcePathKind == "file" } func (request Request) IsDependencyTree() bool { diff --git a/internal/installpolicy/stages.go b/internal/installpolicy/stages.go index 2f87996..9075d1a 100644 --- a/internal/installpolicy/stages.go +++ b/internal/installpolicy/stages.go @@ -34,8 +34,28 @@ type npmPreflightMetadata struct { } func ValidateNPMMetadataPreflight(request Request) error { - if !request.IsNPMMetadataPreflight() { - return errors.New("request is not an OpenClaw npm metadata preflight") + if !request.IsNPMMetadataStage() { + return errors.New("request is not an OpenClaw npm metadata stage") + } + if filepath.Base(filepath.Clean(request.SourcePath)) != "npm-package-metadata.json" { + return errors.New("npm preflight sourcePath must name npm-package-metadata.json") + } + if request.Plugin == nil || + request.Plugin.ContentType != "package" || + strings.TrimSpace(request.Plugin.PackageName) == "" { + return errors.New("npm preflight plugin metadata is inconsistent") + } + if request.Source == nil || + request.Source.Kind != "npm" || + request.Source.Mutable || + !request.Source.Network || + (request.Source.Authority != "official" && request.Source.Authority != "third-party") { + return errors.New("npm preflight source provenance is inconsistent") + } + originType, _ := request.Origin["type"].(string) + originPackageName, _ := request.Origin["packageName"].(string) + if originType != "plugin-npm" || originPackageName != request.Plugin.PackageName { + return errors.New("npm preflight origin provenance is inconsistent") } info, err := os.Lstat(request.SourcePath) if err != nil { diff --git a/internal/installpolicy/stages_test.go b/internal/installpolicy/stages_test.go index 21164ea..4d20b29 100644 --- a/internal/installpolicy/stages_test.go +++ b/internal/installpolicy/stages_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestNPMMetadataPreflightMatchesOnlyFullOpenClawTuple(t *testing.T) { +func TestNPMMetadataStageDoesNotMatchRealPluginFileOrPackageRequests(t *testing.T) { dir := t.TempDir() metadataPath := filepath.Join(dir, "npm-package-metadata.json") writeStageTestFile(t, metadataPath, `{ @@ -16,8 +16,8 @@ func TestNPMMetadataPreflightMatchesOnlyFullOpenClawTuple(t *testing.T) { "resolution":{"name":"@acme/demo","version":"1.2.3"} }`) request := npmMetadataPreflightRequest(metadataPath) - if !request.IsNPMMetadataPreflight() { - t.Fatal("expected exact OpenClaw npm metadata preflight to match") + if !request.IsNPMMetadataStage() { + t.Fatal("expected OpenClaw npm metadata stage to match") } if err := ValidateNPMMetadataPreflight(request); err != nil { t.Fatal(err) @@ -42,56 +42,82 @@ func TestNPMMetadataPreflightMatchesOnlyFullOpenClawTuple(t *testing.T) { request.SourcePath = dir }, }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := npmMetadataPreflightRequest(metadataPath) + test.mutate(&candidate) + if candidate.IsNPMMetadataStage() { + t.Fatalf("ordinary install request matched metadata stage: %#v", candidate) + } + }) + } +} + +func TestValidateNPMMetadataPreflightFailsClosedOnMalformedStageTuple(t *testing.T) { + dir := t.TempDir() + metadataPath := filepath.Join(dir, "npm-package-metadata.json") + writeStageTestFile(t, metadataPath, `{ + "packageName":"@acme/demo", + "requestedSpecifier":"@acme/demo@1.2.3", + "resolution":{"name":"@acme/demo","version":"1.2.3"} + }`) + tests := []struct { + name string + mutate func(*Request) + want string + }{ { - name: "wrong origin", + name: "lookalike filename", mutate: func(request *Request) { - request.Origin["type"] = "plugin-package" + request.SourcePath = filepath.Join(dir, "other.json") }, + want: "must name npm-package-metadata.json", }, { name: "wrong content role", mutate: func(request *Request) { request.Plugin.ContentType = "file" }, + want: "plugin metadata is inconsistent", }, { - name: "wrong source provenance", + name: "missing source provenance", mutate: func(request *Request) { - request.Source.Kind = "local-path" + request.Source = nil }, + want: "source provenance is inconsistent", }, { - name: "lookalike filename", + name: "mutable npm source", mutate: func(request *Request) { - request.SourcePath = filepath.Join(dir, "other.json") + request.Source.Mutable = true }, + want: "source provenance is inconsistent", + }, + { + name: "mismatched origin package", + mutate: func(request *Request) { + request.Origin["packageName"] = "@acme/other" + }, + want: "origin provenance is inconsistent", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - candidate := npmMetadataPreflightRequest(metadataPath) - test.mutate(&candidate) - if candidate.IsNPMMetadataPreflight() { - t.Fatalf("lookalike request matched metadata preflight: %#v", candidate) + request := npmMetadataPreflightRequest(metadataPath) + test.mutate(&request) + if !request.IsNPMMetadataStage() { + t.Fatal("malformed npm metadata tuple escaped stage classification") + } + err := ValidateNPMMetadataPreflight(request) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) } }) } } -func TestValidateNPMMetadataPreflightFailsClosedOnMismatchedProvenance(t *testing.T) { - dir := t.TempDir() - metadataPath := filepath.Join(dir, "npm-package-metadata.json") - writeStageTestFile(t, metadataPath, `{ - "packageName":"@acme/other", - "requestedSpecifier":"@acme/demo@1.2.3", - "resolution":{"name":"@acme/other","version":"1.2.3"} - }`) - err := ValidateNPMMetadataPreflight(npmMetadataPreflightRequest(metadataPath)) - if err == nil || !strings.Contains(err.Error(), "packageName does not match") { - t.Fatalf("error = %v", err) - } -} - func TestPrepareDependencyTreeScanTargetExposesTopLevelAndNestedPackageCode(t *testing.T) { root := t.TempDir() topPackage := filepath.Join(root, "node_modules", "top") From fc319572c409e45c4f06a917b6b560f927c0cf55 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:53:56 +1000 Subject: [PATCH 26/28] fix(policy): reject unmapped judge profiles --- cmd/clawscan/main.go | 5 +++++ cmd/clawscan/main_test.go | 24 ++++++++++++++++++++++++ docs/openclaw-install-policy.md | 4 +++- scripts/build-npm-package.mjs | 3 ++- scripts/build-npm-package.test.mjs | 2 ++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 5695435..dd28ee8 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -141,6 +141,11 @@ func runOpenClawInstallPolicy( opts.TargetKind = request.TargetType opts.JSON = false opts.OutputPath = "" + if opts.Judge != nil { + return failClosed(errors.New( + "OpenClaw install policy does not support judge-backed profiles; use scanner gate rules", + )) + } metadataPreflight := request.IsNPMMetadataStage() if metadataPreflight { if err := installpolicy.ValidateNPMMetadataPreflight(request); err != nil { diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 424b395..071c73e 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -187,6 +187,30 @@ func TestRunOpenClawInstallPolicyFailsClosedWithValidResponse(t *testing.T) { } } +func TestRunOpenClawInstallPolicyRejectsJudgeBackedProfiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "SKILL.md"), "# Safe skill\n") + request := fmt.Sprintf(`{ + "protocolVersion":1, + "targetType":"skill", + "targetName":"demo", + "sourcePath":%q, + "sourcePathKind":"directory", + "source":{"kind":"local-path","authority":"third-party","mutable":true,"network":false}, + "origin":{"type":"skill-directory"}, + "request":{"kind":"skill-install","mode":"install"} + }`, dir) + response := runInstallPolicyTestRequest( + t, + []string{"--profile", "clawhub", "--sandbox", "off"}, + request, + ) + if response.Decision != "block" || + !strings.Contains(response.Reason, "does not support judge-backed profiles") { + t.Fatalf("response = %#v", response) + } +} + func TestRunOpenClawInstallPolicyHandlesNPMInstallStagesSeparately(t *testing.T) { dir := t.TempDir() metadataPath := filepath.Join(dir, "npm-package-metadata.json") diff --git a/docs/openclaw-install-policy.md b/docs/openclaw-install-policy.md index 3156662..d2f1242 100644 --- a/docs/openclaw-install-policy.md +++ b/docs/openclaw-install-policy.md @@ -184,7 +184,9 @@ The configured command is the composition point for multiple checks. ClawScan does not claim an active-scanner singleton and does not replace other policy engines. Operators can select several scanner adapters in one profile or wrap several policy checks behind their configured executable and combine their -responses deterministically. +responses deterministically. Install-policy profiles must express decisions +through scanner gate rules; judge-backed profiles fail closed because ClawScan +does not define a canonical judge-verdict-to-policy mapping. ## Request and response contract diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 2c3a34c..54f1280 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -44,7 +44,8 @@ export function binaryVersionFor(version) { } export function npmDistTagForVersion(version) { - return normalizePackageVersion(version).includes("-") ? "next" : "latest"; + const [release] = normalizePackageVersion(version).split("+", 1); + return release.includes("-") ? "next" : "latest"; } export function platformKeyForTarget(target) { diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index a91a38f..0191bd1 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -31,7 +31,9 @@ describe("normalizePackageVersion", () => { describe("npmDistTagForVersion", () => { it("keeps stable releases on latest and prereleases on next", () => { assert.equal(npmDistTagForVersion("v1.2.3"), "latest"); + assert.equal(npmDistTagForVersion("v1.2.3+build-7"), "latest"); assert.equal(npmDistTagForVersion("1.2.3-beta.1"), "next"); + assert.equal(npmDistTagForVersion("1.2.3-beta.1+build-7"), "next"); }); }); From 8f5a9a1b4a1104f966ed79d43dc3fdd01b18f1cf Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:04:55 +1000 Subject: [PATCH 27/28] fix(release): verify npm dist tags on retry --- .github/workflows/npm-release.yml | 31 ++++++++++++++++++++++++++++++ scripts/build-npm-package.test.mjs | 12 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index fec865d..70e4b45 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -270,3 +270,34 @@ jobs: sleep 5 done printf '%s\n' "$NPM_DIST_JSON" + + TAGGED_VERSION="" + for attempt in {1..12}; do + if TAGGED_VERSION="$(npm view "@openclaw/clawscan@${NPM_DIST_TAG}" version 2>/tmp/npm-tag-view-error)" && + [[ "$TAGGED_VERSION" == "$PACKAGE_VERSION" ]]; then + break + fi + if [[ "$attempt" == "12" ]]; then + cat /tmp/npm-tag-view-error >&2 || true + echo "Expected dist-tag ${NPM_DIST_TAG} to resolve to ${PACKAGE_VERSION}, got ${TAGGED_VERSION:-}." >&2 + exit 1 + fi + sleep 5 + done + + if [[ "$NPM_DIST_TAG" == "next" ]]; then + LATEST_VERSION="" + LATEST_STATUS=0 + set +e + LATEST_VERSION="$(npm view "@openclaw/clawscan@latest" version 2>/tmp/npm-latest-view-error)" + LATEST_STATUS=$? + set -e + if [[ "$LATEST_STATUS" -eq 0 && "$LATEST_VERSION" == "$PACKAGE_VERSION" ]]; then + echo "Prerelease ${PACKAGE_VERSION} must not be assigned to the latest dist-tag." >&2 + exit 1 + fi + if [[ "$LATEST_STATUS" -ne 0 ]] && ! grep -q "E404" /tmp/npm-latest-view-error; then + cat /tmp/npm-latest-view-error >&2 + exit "$LATEST_STATUS" + fi + fi diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index 0191bd1..0c82291 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -87,3 +87,15 @@ describe("GitHub release target mapping", () => { ); }); }); + +describe("npm promotion verification", () => { + it("checks the expected dist-tag even when publication is skipped", () => { + const workflow = readFileSync( + new URL("../.github/workflows/npm-release.yml", import.meta.url), + "utf8", + ); + + assert.match(workflow, /@openclaw\/clawscan@\$\{NPM_DIST_TAG\}/u); + assert.match(workflow, /Prerelease \$\{PACKAGE_VERSION\} must not be assigned to the latest/u); + }); +}); From f91194ef348e18ef7f64a3c1c664209125fb6a0b Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:23:00 +1000 Subject: [PATCH 28/28] test(profiles): retain install policy gate coverage --- cmd/clawscan/main_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index e207735..071c73e 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -587,9 +587,6 @@ profiles: if strings.Contains(stdout, "local-review:") { t.Fatalf("verbose profiles output should not include project profile:\n%s", stdout) } - if strings.Contains(stdout, "gate:") { - t.Fatalf("embedded profiles should preserve their existing gate-free contract:\n%s", stdout) - } } func TestRunCommandBenchmarkListPrintsCatalogTable(t *testing.T) {