reanimator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +207 -0
- data/LICENSE.txt +21 -0
- data/README.md +483 -0
- data/SECURITY.md +32 -0
- data/exe/reanimator +6 -0
- data/lib/reanimator/acceptance_criterion.rb +75 -0
- data/lib/reanimator/agent_connection.rb +62 -0
- data/lib/reanimator/agent_profile.rb +51 -0
- data/lib/reanimator/agents/adapter.rb +49 -0
- data/lib/reanimator/agents/capability.rb +29 -0
- data/lib/reanimator/agents/constraints.rb +57 -0
- data/lib/reanimator/agents/hosted.rb +38 -0
- data/lib/reanimator/agents/local.rb +46 -0
- data/lib/reanimator/agents/patch.rb +60 -0
- data/lib/reanimator/agents/repair_task.rb +51 -0
- data/lib/reanimator/agents/result.rb +42 -0
- data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
- data/lib/reanimator/agents/strategies/base.rb +26 -0
- data/lib/reanimator/agents.rb +38 -0
- data/lib/reanimator/autonomy_level.rb +41 -0
- data/lib/reanimator/baseline.rb +106 -0
- data/lib/reanimator/baseline_runner.rb +81 -0
- data/lib/reanimator/bounded_command.rb +87 -0
- data/lib/reanimator/classifier.rb +89 -0
- data/lib/reanimator/cli.rb +273 -0
- data/lib/reanimator/cohort.rb +61 -0
- data/lib/reanimator/commissioning/fixture.rb +69 -0
- data/lib/reanimator/commissioning/report.rb +45 -0
- data/lib/reanimator/commissioning/test.rb +145 -0
- data/lib/reanimator/confidence.rb +66 -0
- data/lib/reanimator/configuration.rb +35 -0
- data/lib/reanimator/contract_file.rb +116 -0
- data/lib/reanimator/contract_snapshot.rb +188 -0
- data/lib/reanimator/criteria_stack.rb +91 -0
- data/lib/reanimator/criterion_result.rb +70 -0
- data/lib/reanimator/detectors/base.rb +49 -0
- data/lib/reanimator/detectors/bundler.rb +68 -0
- data/lib/reanimator/detectors/commands.rb +112 -0
- data/lib/reanimator/detectors/fork.rb +25 -0
- data/lib/reanimator/detectors/github_actions.rb +68 -0
- data/lib/reanimator/detectors/operational_proof.rb +48 -0
- data/lib/reanimator/detectors/rails_app.rb +51 -0
- data/lib/reanimator/detectors/relationships.rb +46 -0
- data/lib/reanimator/detectors/ruby_gem.rb +40 -0
- data/lib/reanimator/detectors/security_controls.rb +36 -0
- data/lib/reanimator/discovered_fact.rb +99 -0
- data/lib/reanimator/discovery.rb +48 -0
- data/lib/reanimator/discovery_result.rb +89 -0
- data/lib/reanimator/drift_detector.rb +227 -0
- data/lib/reanimator/drift_report.rb +86 -0
- data/lib/reanimator/effective_contract.rb +108 -0
- data/lib/reanimator/effective_policy_resolver.rb +116 -0
- data/lib/reanimator/errors.rb +21 -0
- data/lib/reanimator/evaluators/base.rb +55 -0
- data/lib/reanimator/evaluators/command.rb +34 -0
- data/lib/reanimator/evaluators/documentation_check.rb +25 -0
- data/lib/reanimator/evaluators/http_smoke.rb +19 -0
- data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
- data/lib/reanimator/evaluators/security_evidence.rb +24 -0
- data/lib/reanimator/evaluators/support_matrix.rb +25 -0
- data/lib/reanimator/evaluators.rb +41 -0
- data/lib/reanimator/family/gate.rb +40 -0
- data/lib/reanimator/family/gate_result.rb +46 -0
- data/lib/reanimator/family/gate_runner.rb +91 -0
- data/lib/reanimator/family.rb +89 -0
- data/lib/reanimator/family_inference.rb +113 -0
- data/lib/reanimator/git.rb +72 -0
- data/lib/reanimator/isolation.rb +36 -0
- data/lib/reanimator/language_inference.rb +43 -0
- data/lib/reanimator/lifecycle_intent.rb +93 -0
- data/lib/reanimator/obligation.rb +35 -0
- data/lib/reanimator/permission_model.rb +61 -0
- data/lib/reanimator/portfolio.rb +68 -0
- data/lib/reanimator/portfolio_drift.rb +120 -0
- data/lib/reanimator/profile_inference.rb +37 -0
- data/lib/reanimator/profiles.rb +110 -0
- data/lib/reanimator/proof_of_life.rb +45 -0
- data/lib/reanimator/proposed_change.rb +55 -0
- data/lib/reanimator/provenance.rb +39 -0
- data/lib/reanimator/relationship.rb +62 -0
- data/lib/reanimator/repair_run.rb +146 -0
- data/lib/reanimator/reports/agents.rb +28 -0
- data/lib/reanimator/reports/baseline.rb +73 -0
- data/lib/reanimator/reports/commissioning.rb +48 -0
- data/lib/reanimator/reports/contract.rb +70 -0
- data/lib/reanimator/reports/discovery.rb +38 -0
- data/lib/reanimator/reports/drift.rb +63 -0
- data/lib/reanimator/reports/families.rb +41 -0
- data/lib/reanimator/reports/family_gates.rb +41 -0
- data/lib/reanimator/reports/github_check.rb +45 -0
- data/lib/reanimator/reports/inventory.rb +37 -0
- data/lib/reanimator/reports/permissions.rb +32 -0
- data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
- data/lib/reanimator/reports/portfolio_drift.rb +107 -0
- data/lib/reanimator/reports/run.rb +52 -0
- data/lib/reanimator/reports/snapshot.rb +58 -0
- data/lib/reanimator/reports.rb +36 -0
- data/lib/reanimator/repository.rb +96 -0
- data/lib/reanimator/repository_metadata.rb +78 -0
- data/lib/reanimator/repository_state.rb +48 -0
- data/lib/reanimator/routing/decision.rb +35 -0
- data/lib/reanimator/routing/router.rb +122 -0
- data/lib/reanimator/run.rb +48 -0
- data/lib/reanimator/safety_floor.rb +81 -0
- data/lib/reanimator/security_control.rb +87 -0
- data/lib/reanimator/security_posture.rb +80 -0
- data/lib/reanimator/snapshot_store.rb +78 -0
- data/lib/reanimator/task_class.rb +63 -0
- data/lib/reanimator/version.rb +5 -0
- data/lib/reanimator/vitality_profile.rb +44 -0
- data/lib/reanimator/waiver.rb +74 -0
- data/lib/reanimator.rb +214 -0
- metadata +262 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The product safety floor (specification section 07): the non-bypassable
|
|
5
|
+
# bottom of the precedence stack. No layer above it - organization,
|
|
6
|
+
# portfolio, family, repository, or task - may loosen these. The floor is
|
|
7
|
+
# what makes the anti-gaming rule enforceable: "a task that modifies the
|
|
8
|
+
# rules of proof cannot use those modified rules to approve itself."
|
|
9
|
+
module SafetyFloor
|
|
10
|
+
# Paths that always receive elevated scrutiny because editing them can
|
|
11
|
+
# weaken a check, change who is trusted, or alter what "proof" means. A
|
|
12
|
+
# repository contract may *add* to this set but never remove from it.
|
|
13
|
+
PROTECTED_PATHS = [
|
|
14
|
+
".reanimator.yml",
|
|
15
|
+
".github/workflows/**",
|
|
16
|
+
".github/dependabot.yml",
|
|
17
|
+
"CODEOWNERS",
|
|
18
|
+
".github/CODEOWNERS"
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Merge is off in v1 regardless of what any layer requests (section 18:
|
|
22
|
+
# automatic merge is explicitly later).
|
|
23
|
+
MERGE_IN_V1 = :disabled
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The union of the floor's protected paths and any the contract declares.
|
|
28
|
+
# @param declared [Array<String>]
|
|
29
|
+
# @return [Array<String>]
|
|
30
|
+
def protected_paths(declared = [])
|
|
31
|
+
(PROTECTED_PATHS + Array(declared)).uniq
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Matches a repository-relative path against a glob, treating a trailing
|
|
35
|
+
# `**` as "this directory and everything under it, at any depth". Plain
|
|
36
|
+
# `File.fnmatch?` with FNM_PATHNAME does not let `**` cross `/`, which would
|
|
37
|
+
# leave `.github/workflows/nested/deploy.yml` unprotected - so the recursive
|
|
38
|
+
# form is expanded and tried as well.
|
|
39
|
+
#
|
|
40
|
+
# @param glob [String]
|
|
41
|
+
# @param path [String]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def matches?(glob, path)
|
|
44
|
+
flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
45
|
+
return true if File.fnmatch?(glob, path, flags)
|
|
46
|
+
|
|
47
|
+
recursive = glob.sub(%r{/\*\*\z}, "/**/*")
|
|
48
|
+
recursive != glob && File.fnmatch?(recursive, path, flags)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether any of the given globs protects the path.
|
|
52
|
+
def protects?(path, globs)
|
|
53
|
+
globs.any? { |glob| matches?(glob, path) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clamps an autonomy map so it can never exceed the floor. Returns the
|
|
57
|
+
# clamped map plus a list of human-readable notes describing what was
|
|
58
|
+
# tightened, so the resolver can surface the clamp rather than hide it.
|
|
59
|
+
#
|
|
60
|
+
# @param autonomy [Hash]
|
|
61
|
+
# @return [Array(Hash, Array<String>)]
|
|
62
|
+
def clamp_autonomy(autonomy)
|
|
63
|
+
clamped = autonomy.dup
|
|
64
|
+
notes = []
|
|
65
|
+
|
|
66
|
+
if clamped[:merge] && clamped[:merge].to_sym != MERGE_IN_V1
|
|
67
|
+
notes << "Merge autonomy was requested but is disabled by the v1 safety floor."
|
|
68
|
+
clamped[:merge] = MERGE_IN_V1
|
|
69
|
+
end
|
|
70
|
+
clamped[:merge] ||= MERGE_IN_V1
|
|
71
|
+
|
|
72
|
+
[clamped, notes]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Verifier isolation is always on: evaluation runs separately from any
|
|
76
|
+
# agent workspace, and an acting agent can never alter the effective
|
|
77
|
+
# contract, hidden tests, or baseline evidence during an ordinary repair
|
|
78
|
+
# (section 09). This is a constant, not a setting.
|
|
79
|
+
def verifier_isolation? = true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A family of security evidence a posture can require (specification section
|
|
5
|
+
# 12). Controls are named by *obligation* rather than by tool: "dependency
|
|
6
|
+
# risk is reviewed" is satisfied by Dependabot, bundler-audit, or the
|
|
7
|
+
# dependency-review action alike, so a repository that already solved the
|
|
8
|
+
# problem its own way is credited rather than asked to adopt ours.
|
|
9
|
+
class SecurityControl
|
|
10
|
+
REGISTRY = {
|
|
11
|
+
dependency_review: {
|
|
12
|
+
claim: "Dependency risk is reviewed automatically.",
|
|
13
|
+
satisfied_by: %i[dependabot security_dependency_review security_bundler_dependency_audit].freeze,
|
|
14
|
+
remedy: "No dependency review found. Add .github/dependabot.yml, bundler-audit, or actions/dependency-review-action."
|
|
15
|
+
},
|
|
16
|
+
secret_scanning: {
|
|
17
|
+
claim: "Committed secrets are scanned for.",
|
|
18
|
+
satisfied_by: %i[security_secret_scanning_gitleaks security_secret_scanning_trufflehog].freeze,
|
|
19
|
+
remedy: "No secret scanning found. Add gitleaks or trufflehog to CI."
|
|
20
|
+
},
|
|
21
|
+
static_analysis: {
|
|
22
|
+
claim: "Source is scanned for known vulnerability patterns.",
|
|
23
|
+
satisfied_by: %i[security_codeql_sast security_brakeman_sast].freeze,
|
|
24
|
+
remedy: "No static analysis found. Add CodeQL or Brakeman to CI."
|
|
25
|
+
},
|
|
26
|
+
security_policy: {
|
|
27
|
+
claim: "A security policy says how to report a vulnerability.",
|
|
28
|
+
satisfied_by: %i[security_policy].freeze,
|
|
29
|
+
remedy: "No security policy found. Add SECURITY.md."
|
|
30
|
+
},
|
|
31
|
+
ownership: {
|
|
32
|
+
claim: "Ownership of protected paths is declared.",
|
|
33
|
+
satisfied_by: %i[codeowners].freeze,
|
|
34
|
+
remedy: "No ownership declaration found. Add CODEOWNERS."
|
|
35
|
+
}
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
attr_reader :id, :claim, :satisfied_by, :remedy
|
|
39
|
+
|
|
40
|
+
def initialize(id)
|
|
41
|
+
@id = id.to_sym
|
|
42
|
+
spec = REGISTRY.fetch(@id) do
|
|
43
|
+
raise UnknownError, "Unknown security control #{id.inspect}. Known: #{REGISTRY.keys.join(", ")}."
|
|
44
|
+
end
|
|
45
|
+
@claim = spec[:claim]
|
|
46
|
+
@satisfied_by = spec[:satisfied_by]
|
|
47
|
+
@remedy = spec[:remedy]
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.fetch(id) = new(id)
|
|
52
|
+
|
|
53
|
+
def self.ids = REGISTRY.keys
|
|
54
|
+
|
|
55
|
+
def self.all = ids.map { |id| new(id) }
|
|
56
|
+
|
|
57
|
+
# How a set of controls reads in a report: their plain names, or "none" -
|
|
58
|
+
# so a posture that asks for nothing says so instead of trailing off.
|
|
59
|
+
def self.labels(ids)
|
|
60
|
+
return "none" if ids.empty?
|
|
61
|
+
|
|
62
|
+
ids.map { |id| fetch(id).label }.join(", ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The criterion id this control is graded under. The control owns its own
|
|
66
|
+
# naming so reports, waivers, and history cannot drift apart. Ids are
|
|
67
|
+
# already namespaced under "security-", so a control named for security
|
|
68
|
+
# drops the prefix rather than stuttering as security-security-policy.
|
|
69
|
+
def criterion_id = :"security-#{id.to_s.delete_prefix("security_").tr("_", "-")}"
|
|
70
|
+
|
|
71
|
+
# The first discovered fact that satisfies this control, or nil. Any one of
|
|
72
|
+
# several fingerprints counts: the obligation is what matters, not the tool.
|
|
73
|
+
#
|
|
74
|
+
# @param discovery [DiscoveryResult]
|
|
75
|
+
# @return [DiscoveredFact, nil]
|
|
76
|
+
def evidence_in(discovery)
|
|
77
|
+
satisfied_by.filter_map { |key| discovery[key] }.first
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Human-readable name for reports, e.g. "dependency review".
|
|
81
|
+
def label = id.to_s.tr("_", " ")
|
|
82
|
+
|
|
83
|
+
def to_sym = id
|
|
84
|
+
|
|
85
|
+
def to_s = id.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The security posture presets from specification section 12. Security is
|
|
5
|
+
# part of onboarding, not a hidden settings page, so the posture is a
|
|
6
|
+
# first-class, named value with plain-language behavior - never a bundle of
|
|
7
|
+
# silent relaxations.
|
|
8
|
+
class SecurityPosture
|
|
9
|
+
PRESETS = {
|
|
10
|
+
strict: "Approved providers only, deny-by-default network and secrets, " \
|
|
11
|
+
"protected workflows, no auto-merge, enhanced review for verifier changes.",
|
|
12
|
+
balanced: "Approved package-registry network, hosted agents allowed, " \
|
|
13
|
+
"patch submission permitted, no major automatic repair.",
|
|
14
|
+
local: "Code and execution remain on user-controlled runners; external providers excluded.",
|
|
15
|
+
custom: "Explicit controls with no hidden relaxation from a preset."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# What each posture makes mandatory (section 12). The posture - not the
|
|
19
|
+
# project's shape - decides how much security proof a repository must show,
|
|
20
|
+
# so the same gem is advisory under :balanced and blocking under :strict.
|
|
21
|
+
# A control absent from a posture's map is not asked for at all.
|
|
22
|
+
#
|
|
23
|
+
# :local requires secret scanning because committed secrets are the one
|
|
24
|
+
# risk that does not care whether the runner is yours. :custom implies
|
|
25
|
+
# nothing: "no hidden relaxation from a preset" cuts both ways, so the
|
|
26
|
+
# owner declares the controls they want and Reanimator adds none.
|
|
27
|
+
OBLIGATIONS = {
|
|
28
|
+
strict: {
|
|
29
|
+
security_policy: true, dependency_review: true, secret_scanning: true,
|
|
30
|
+
static_analysis: false, ownership: false
|
|
31
|
+
},
|
|
32
|
+
balanced: {dependency_review: false, secret_scanning: false, security_policy: false},
|
|
33
|
+
local: {secret_scanning: true, dependency_review: false},
|
|
34
|
+
custom: {}
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :name
|
|
38
|
+
|
|
39
|
+
def initialize(name)
|
|
40
|
+
@name = name.to_sym
|
|
41
|
+
unless PRESETS.key?(@name)
|
|
42
|
+
raise UnknownError, "Unknown security posture #{name.inspect}. Known: #{PRESETS.keys.join(", ")}."
|
|
43
|
+
end
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.[](name) = new(name)
|
|
48
|
+
|
|
49
|
+
# Section 20 v1 default: balanced for public repositories, strict for
|
|
50
|
+
# private repositories unless changed.
|
|
51
|
+
def self.default_for(visibility)
|
|
52
|
+
(visibility.to_s == "private") ? new(:strict) : new(:balanced)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def description = PRESETS.fetch(name)
|
|
56
|
+
|
|
57
|
+
# Network egress the posture permits by default.
|
|
58
|
+
def network
|
|
59
|
+
case name
|
|
60
|
+
when :strict, :local then :deny
|
|
61
|
+
when :balanced then :package_registries
|
|
62
|
+
else :custom
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash{Symbol=>Boolean}] security control id => whether a missing
|
|
67
|
+
# control blocks advancement under this posture
|
|
68
|
+
def obligations = OBLIGATIONS.fetch(name)
|
|
69
|
+
|
|
70
|
+
# @return [Array<Symbol>] controls whose absence blocks
|
|
71
|
+
def required_controls = obligations.select { |_, required| required }.keys
|
|
72
|
+
|
|
73
|
+
# @return [Array<Symbol>] controls reported but not blocking
|
|
74
|
+
def advisory_controls = obligations.reject { |_, required| required }.keys
|
|
75
|
+
|
|
76
|
+
def to_sym = name
|
|
77
|
+
|
|
78
|
+
def to_s = name.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# Where a repository's recorded contract snapshot lives - and the only place
|
|
7
|
+
# Reanimator ever writes one (specification section 06).
|
|
8
|
+
#
|
|
9
|
+
# There are two conventions, because drift has two shapes. A repository you
|
|
10
|
+
# own records its snapshot *inside itself*, as a committed file beside
|
|
11
|
+
# `.reanimator.yml`: the contract and the record of what it last required
|
|
12
|
+
# are the same kind of thing, and keeping both under review means a policy
|
|
13
|
+
# change arrives as a diff a human reads rather than as state hidden in a
|
|
14
|
+
# tool. A portfolio of repositories you cannot write into records them side
|
|
15
|
+
# by side in one directory instead, keyed by repository.
|
|
16
|
+
class SnapshotStore
|
|
17
|
+
DEFAULT_FILENAME = ".reanimator-snapshot.json"
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] the portfolio-side directory, or nil when each
|
|
20
|
+
# repository keeps its own committed snapshot
|
|
21
|
+
attr_reader :directory
|
|
22
|
+
|
|
23
|
+
def initialize(directory: nil)
|
|
24
|
+
@directory = directory
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param repository [Repository]
|
|
28
|
+
# @return [String] where this repository's snapshot is read from and written to
|
|
29
|
+
def path_for(repository)
|
|
30
|
+
return File.join(repository.path, DEFAULT_FILENAME) unless directory
|
|
31
|
+
|
|
32
|
+
File.join(directory, "#{key_for(repository)}.json")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def recorded?(repository) = File.file?(path_for(repository))
|
|
36
|
+
|
|
37
|
+
# @param repository [Repository]
|
|
38
|
+
# @return [ContractSnapshot, nil] nil when nothing has been recorded yet
|
|
39
|
+
def load(repository)
|
|
40
|
+
recorded?(repository) ? ContractSnapshot.load(path_for(repository)) : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param snapshot [ContractSnapshot]
|
|
44
|
+
# @param repository [Repository]
|
|
45
|
+
# @return [String] the path written
|
|
46
|
+
def save(snapshot, repository)
|
|
47
|
+
destination = path_for(repository)
|
|
48
|
+
FileUtils.mkdir_p(directory) if directory
|
|
49
|
+
File.write(destination, snapshot.to_json)
|
|
50
|
+
destination
|
|
51
|
+
rescue SystemCallError => e
|
|
52
|
+
raise ContractError, "Could not write the contract snapshot to #{destination}: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The exact command a maintainer runs to re-record a snapshot, for a report
|
|
56
|
+
# to quote. The store is the one place that knows where snapshots live, so
|
|
57
|
+
# it is also the one place that knows how to say "put it back".
|
|
58
|
+
#
|
|
59
|
+
# @param file [String, nil] an explicitly named snapshot file, if the
|
|
60
|
+
# maintainer bypassed the store with one
|
|
61
|
+
def record_command(file = nil)
|
|
62
|
+
return "reanimator drift --save #{file}" if file
|
|
63
|
+
return "reanimator drift --record --snapshots #{directory}" if directory
|
|
64
|
+
|
|
65
|
+
"reanimator drift --record"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# A repository's identity flattened into one filename. A collision is not a
|
|
71
|
+
# correctness risk: {DriftDetector} refuses to compare snapshots from two
|
|
72
|
+
# different repositories, so a mis-keyed file fails closed instead of
|
|
73
|
+
# reporting the most dangerous possible answer - "no drift".
|
|
74
|
+
def key_for(repository)
|
|
75
|
+
repository.full_name.to_s.gsub(/[^A-Za-z0-9._-]+/, "-")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A category of maintenance work with a default autonomy posture per rung
|
|
5
|
+
# (specification section 11, "Default autonomy by task class"). The table is
|
|
6
|
+
# deliberately conservative: security remediation may repair and submit, but
|
|
7
|
+
# major upgrades only plan, verifier changes demand elevated review, and
|
|
8
|
+
# nothing merges automatically.
|
|
9
|
+
class TaskClass
|
|
10
|
+
# Per-rung default policy tokens used in the section 11 table.
|
|
11
|
+
# :auto run automatically
|
|
12
|
+
# :allowed permitted when the rung is enabled
|
|
13
|
+
# :draft permitted, but only as a draft PR
|
|
14
|
+
# :draft_elevated draft PR plus elevated review
|
|
15
|
+
# :plan_only may only produce a plan
|
|
16
|
+
# :issue may only open an issue
|
|
17
|
+
# :optional permitted but not default
|
|
18
|
+
# :by_exception only when explicitly excepted
|
|
19
|
+
# :off not permitted
|
|
20
|
+
attr_reader :key, :title, :defaults, :elevated_review
|
|
21
|
+
|
|
22
|
+
def initialize(key:, title:, defaults:, elevated_review: false)
|
|
23
|
+
@key = key
|
|
24
|
+
@title = title
|
|
25
|
+
@defaults = defaults
|
|
26
|
+
@elevated_review = elevated_review
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def elevated_review? = @elevated_review
|
|
31
|
+
|
|
32
|
+
def default_for(level) = defaults.fetch(level.to_sym, :off)
|
|
33
|
+
|
|
34
|
+
def self.fetch(key)
|
|
35
|
+
ALL.fetch(key.to_sym) { raise UnknownError, "Unknown task class #{key.inspect}. Known: #{ALL.keys.join(", ")}." }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.keys = ALL.keys
|
|
39
|
+
|
|
40
|
+
def self.build(key, title, observe:, diagnose:, repair:, submit:, merge:, elevated_review: false)
|
|
41
|
+
new(key: key, title: title, elevated_review: elevated_review,
|
|
42
|
+
defaults: {observe: observe, diagnose: diagnose, repair: repair, submit: submit, merge: merge})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ALL = {
|
|
46
|
+
critical_security: build(:critical_security, "Critical security remediation",
|
|
47
|
+
observe: :auto, diagnose: :auto, repair: :allowed, submit: :allowed, merge: :off),
|
|
48
|
+
patch_dependency: build(:patch_dependency, "Patch dependency update",
|
|
49
|
+
observe: :auto, diagnose: :auto, repair: :allowed, submit: :allowed, merge: :off),
|
|
50
|
+
minor_dependency: build(:minor_dependency, "Minor dependency update",
|
|
51
|
+
observe: :auto, diagnose: :auto, repair: :allowed, submit: :draft, merge: :off),
|
|
52
|
+
major_upgrade: build(:major_upgrade, "Major dependency or runtime update",
|
|
53
|
+
observe: :auto, diagnose: :auto, repair: :plan_only, submit: :issue, merge: :off),
|
|
54
|
+
mechanical_formatting: build(:mechanical_formatting, "Mechanical formatting",
|
|
55
|
+
observe: :auto, diagnose: :auto, repair: :optional, submit: :draft, merge: :off),
|
|
56
|
+
behavioral_refactor: build(:behavioral_refactor, "Behavioral refactor",
|
|
57
|
+
observe: :auto, diagnose: :auto, repair: :by_exception, submit: :draft, merge: :off),
|
|
58
|
+
verifier_modification: build(:verifier_modification, "CI or verifier modification",
|
|
59
|
+
observe: :auto, diagnose: :auto, repair: :by_exception, submit: :draft_elevated, merge: :off,
|
|
60
|
+
elevated_review: true)
|
|
61
|
+
}.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A reusable bundle of inferred or declared obligations for a common project
|
|
5
|
+
# shape - ruby-gem, rails-app, ruby-cli (specification section 02). A
|
|
6
|
+
# profile says what "healthy" *usually* looks like; it removes repetitive
|
|
7
|
+
# configuration without hiding the resulting contract. Profiles compose:
|
|
8
|
+
# rails-app inherits from web-application, and both resolve against the
|
|
9
|
+
# detected evidence of a specific repository.
|
|
10
|
+
class VitalityProfile
|
|
11
|
+
# @return [Symbol] stable profile id, e.g. :"ruby-gem"
|
|
12
|
+
attr_reader :id
|
|
13
|
+
# @return [String] one-line description
|
|
14
|
+
attr_reader :summary
|
|
15
|
+
# @return [Array<Symbol>] profiles this one inherits obligations from
|
|
16
|
+
attr_reader :inherits
|
|
17
|
+
# @return [Array<AcceptanceCriterion>] criteria contributed by this profile
|
|
18
|
+
attr_reader :criteria
|
|
19
|
+
|
|
20
|
+
def initialize(id:, summary:, inherits: [], criteria: [])
|
|
21
|
+
@id = id.to_sym
|
|
22
|
+
@summary = summary
|
|
23
|
+
@inherits = Array(inherits).map(&:to_sym)
|
|
24
|
+
@criteria = criteria
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The fully-resolved criteria for this profile, including inherited ones,
|
|
29
|
+
# de-duplicated by id with the most specific (this profile) winning.
|
|
30
|
+
#
|
|
31
|
+
# @param registry [#fetch] profile lookup, defaults to {Profiles}
|
|
32
|
+
# @return [Array<AcceptanceCriterion>]
|
|
33
|
+
def resolved_criteria(registry: Profiles)
|
|
34
|
+
inherited = inherits.flat_map { |parent| registry.fetch(parent).resolved_criteria(registry: registry) }
|
|
35
|
+
by_id = {}
|
|
36
|
+
(inherited + criteria).each { |c| by_id[c.id] = c }
|
|
37
|
+
by_id.values
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{id: id, summary: summary, inherits: inherits}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Reanimator
|
|
7
|
+
# An explicit, scoped, attributable exception to an obligation (specification
|
|
8
|
+
# section 09). A waiver does not make a failing criterion pass - "a waived
|
|
9
|
+
# obligation is not counted as verified; it is counted as accepted risk". It
|
|
10
|
+
# is time-bounded when appropriate: past its expiry it stops applying and the
|
|
11
|
+
# obligation is blocking again (fail closed). A waiver always carries a
|
|
12
|
+
# rationale, so accepted risk is never anonymous.
|
|
13
|
+
class Waiver
|
|
14
|
+
# @return [Symbol] the acceptance-criterion id this waiver accepts
|
|
15
|
+
attr_reader :target
|
|
16
|
+
# @return [String] why the risk is accepted (required)
|
|
17
|
+
attr_reader :reason
|
|
18
|
+
# @return [String] who accepted it
|
|
19
|
+
attr_reader :owner
|
|
20
|
+
# @return [Hash] optional scope (environments, paths, family members)
|
|
21
|
+
attr_reader :scope
|
|
22
|
+
# @return [Time, nil] when the waiver stops applying, or nil for open-ended
|
|
23
|
+
attr_reader :expires
|
|
24
|
+
|
|
25
|
+
def initialize(target:, reason:, owner: "repository", scope: {}, expires: nil)
|
|
26
|
+
@target = target.to_sym
|
|
27
|
+
@reason = reason.to_s
|
|
28
|
+
@owner = owner
|
|
29
|
+
@scope = scope || {}
|
|
30
|
+
@expires = coerce_time(expires)
|
|
31
|
+
if @reason.strip.empty?
|
|
32
|
+
raise ContractError, "Waiver for #{target.inspect} needs a reason; accepted risk must be attributable."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param criterion_id [Symbol, String]
|
|
37
|
+
def covers?(criterion_id) = target == criterion_id.to_sym
|
|
38
|
+
|
|
39
|
+
# How this waiver reads on a result it accepts. The waiver owns its reason
|
|
40
|
+
# and bound, so it phrases its own acceptance rather than leaving callers
|
|
41
|
+
# to assemble the sentence.
|
|
42
|
+
#
|
|
43
|
+
# @param result [CriterionResult] the result being accepted
|
|
44
|
+
# @return [String]
|
|
45
|
+
def acceptance_of(result)
|
|
46
|
+
bound = expires ? " until #{expires.strftime("%Y-%m-%d")}" : ""
|
|
47
|
+
"Accepted risk#{bound}: #{reason} (was #{result.status})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether the waiver still applies at the given time.
|
|
51
|
+
def active?(now: Time.now) = expires.nil? || now < expires
|
|
52
|
+
|
|
53
|
+
def expired?(now: Time.now) = !active?(now: now)
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{target: target, reason: reason, owner: owner, expires: expires&.iso8601}.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Accepts a Date or Time (from YAML) or a parseable string; nil means
|
|
62
|
+
# open-ended.
|
|
63
|
+
def coerce_time(value)
|
|
64
|
+
case value
|
|
65
|
+
when nil then nil
|
|
66
|
+
when Time then value
|
|
67
|
+
when Date then value.to_time
|
|
68
|
+
else Time.parse(value.to_s)
|
|
69
|
+
end
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
raise ContractError, "Waiver for #{target.inspect} has an invalid expires: #{value.inspect}."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|