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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# What changed between two contract snapshots, and which of those changes a
|
|
5
|
+
# human has to look at (specification section 06).
|
|
6
|
+
#
|
|
7
|
+
# The distinction the specification insists on is not "big" versus "small" -
|
|
8
|
+
# it is *policy* versus *benign*. A renamed test command is benign: the
|
|
9
|
+
# obligation is unchanged, only its spelling moved. A removed security gate
|
|
10
|
+
# or a narrowed runtime matrix is a policy change: less is being proven than
|
|
11
|
+
# before, and nobody necessarily decided that on purpose. Drift detection
|
|
12
|
+
# earns its keep by never burying the second kind in a list of the first.
|
|
13
|
+
class DriftReport
|
|
14
|
+
# A single difference between two snapshots, carrying the verdict that
|
|
15
|
+
# judged it - a severity and the sentence justifying that severity - so no
|
|
16
|
+
# caller has to re-derive the judgement in order to display it. The verdict
|
|
17
|
+
# arrives whole and is kept whole: a severity can never be paired with the
|
|
18
|
+
# reasoning for a different one.
|
|
19
|
+
class Change
|
|
20
|
+
attr_reader :subject, :kind, :from, :to, :verdict
|
|
21
|
+
|
|
22
|
+
def initialize(subject:, kind:, verdict:, from: nil, to: nil)
|
|
23
|
+
@subject = subject
|
|
24
|
+
@kind = kind
|
|
25
|
+
@verdict = verdict
|
|
26
|
+
@from = from
|
|
27
|
+
@to = to
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def severity = verdict.severity
|
|
32
|
+
|
|
33
|
+
def explanation = verdict.explanation
|
|
34
|
+
|
|
35
|
+
def policy? = severity == :policy
|
|
36
|
+
|
|
37
|
+
def benign? = severity == :benign
|
|
38
|
+
|
|
39
|
+
# A single line naming the difference.
|
|
40
|
+
def summary
|
|
41
|
+
case kind
|
|
42
|
+
when :added then "#{subject} added: #{to}"
|
|
43
|
+
when :removed then "#{subject} removed: #{from}"
|
|
44
|
+
else "#{subject}: #{from} → #{to}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{subject: subject, kind: kind, from: from, to: to, severity: severity, explanation: explanation}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :repository, :previous, :current, :changes
|
|
54
|
+
|
|
55
|
+
def initialize(repository:, previous:, current:, changes:)
|
|
56
|
+
@repository = repository
|
|
57
|
+
@previous = previous
|
|
58
|
+
@current = current
|
|
59
|
+
@changes = changes
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def policy_changes = changes.select(&:policy?)
|
|
64
|
+
|
|
65
|
+
def benign_changes = changes.select(&:benign?)
|
|
66
|
+
|
|
67
|
+
def drifted? = changes.any?
|
|
68
|
+
|
|
69
|
+
# Whether a human has to look. Benign drift is reported but does not
|
|
70
|
+
# demand attention; policy drift always does.
|
|
71
|
+
def requires_review? = policy_changes.any?
|
|
72
|
+
|
|
73
|
+
def since = previous.taken_at
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
repository: repository,
|
|
78
|
+
since: since,
|
|
79
|
+
drifted: drifted?,
|
|
80
|
+
requires_review: requires_review?,
|
|
81
|
+
policy_changes: policy_changes.map(&:to_h),
|
|
82
|
+
benign_changes: benign_changes.map(&:to_h)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# The fully resolved combination of policy layers plus accepted discoveries
|
|
7
|
+
# (specification section 02): exactly what Reanimator must prove for one
|
|
8
|
+
# repository. It is what the contract-review UI shows, what the baseline
|
|
9
|
+
# runs, and what every run is versioned against. It knows how to render
|
|
10
|
+
# itself back into a minimal, round-trippable .reanimator.yml.
|
|
11
|
+
class EffectiveContract
|
|
12
|
+
attr_reader :repository, :intent, :profile, :criteria, :discovery,
|
|
13
|
+
:security_posture, :autonomy, :protected_paths, :waivers, :notes,
|
|
14
|
+
:profile_source, :intent_confirmed
|
|
15
|
+
|
|
16
|
+
def initialize(repository:, intent:, profile:, criteria:, discovery:,
|
|
17
|
+
security_posture:, autonomy:, protected_paths:, profile_source:,
|
|
18
|
+
intent_confirmed:, waivers: [], notes: [])
|
|
19
|
+
@repository = repository
|
|
20
|
+
@intent = intent
|
|
21
|
+
@profile = profile
|
|
22
|
+
@criteria = criteria
|
|
23
|
+
@discovery = discovery
|
|
24
|
+
@security_posture = security_posture
|
|
25
|
+
@autonomy = autonomy
|
|
26
|
+
@protected_paths = protected_paths
|
|
27
|
+
@waivers = waivers
|
|
28
|
+
@profile_source = profile_source
|
|
29
|
+
@intent_confirmed = intent_confirmed
|
|
30
|
+
@notes = notes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The active waiver accepting a criterion's risk, or nil. Expired waivers
|
|
34
|
+
# never apply (fail closed).
|
|
35
|
+
def waiver_for(criterion_id, now: Time.now)
|
|
36
|
+
waivers.find { |waiver| waiver.covers?(criterion_id) && waiver.active?(now: now) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def intent_confirmed? = @intent_confirmed
|
|
40
|
+
|
|
41
|
+
# @return [Array<AcceptanceCriterion>] criteria whose failure blocks
|
|
42
|
+
def required_criteria = criteria.select(&:required?)
|
|
43
|
+
|
|
44
|
+
# @return [Array<AcceptanceCriterion>] non-blocking criteria
|
|
45
|
+
def optional_criteria = criteria.reject(&:required?)
|
|
46
|
+
|
|
47
|
+
# Criteria that touch verifier definitions or protected contract surfaces
|
|
48
|
+
# and therefore always receive elevated review (section 09).
|
|
49
|
+
def verifier_criteria = criteria.select(&:elevated?)
|
|
50
|
+
|
|
51
|
+
# A short, stable fingerprint of the materialized contract, so each run can
|
|
52
|
+
# be attributed to the exact policy it was measured against (section 17:
|
|
53
|
+
# "version the effective contract used for each run").
|
|
54
|
+
def policy_version
|
|
55
|
+
@policy_version ||= "#{profile.id}@#{Digest::SHA256.hexdigest(canonical.to_s)[0, 12]}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The minimal, owner-facing declaration. This is the target for ordinary
|
|
59
|
+
# configuration (section 05): one lifecycle decision and one profile.
|
|
60
|
+
def to_minimal_hash
|
|
61
|
+
{version: ContractFile::CURRENT_VERSION, intent: intent.to_sym, profile: profile.id}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The fuller contract hash used for advanced export. Must round-trip
|
|
65
|
+
# through {ContractFile} without semantic loss (section 07).
|
|
66
|
+
def to_contract_hash
|
|
67
|
+
to_minimal_hash.merge(
|
|
68
|
+
acceptance: {
|
|
69
|
+
checks: criteria.map { |c| c.to_h.slice(:id, :evaluator, :run, :target, :required).compact }
|
|
70
|
+
},
|
|
71
|
+
security: {posture: security_posture.to_sym},
|
|
72
|
+
autonomy: autonomy,
|
|
73
|
+
protected_paths: protected_paths
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
repository: repository.full_name,
|
|
80
|
+
intent: intent.to_sym,
|
|
81
|
+
intent_confirmed: intent_confirmed?,
|
|
82
|
+
profile: profile.id,
|
|
83
|
+
profile_source: profile_source,
|
|
84
|
+
security_posture: security_posture.to_sym,
|
|
85
|
+
autonomy: autonomy,
|
|
86
|
+
protected_paths: protected_paths,
|
|
87
|
+
policy_version: policy_version,
|
|
88
|
+
criteria: criteria.map(&:to_h),
|
|
89
|
+
notes: notes
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The subset that defines the contract's identity for fingerprinting.
|
|
96
|
+
def canonical
|
|
97
|
+
{
|
|
98
|
+
intent: intent.to_sym,
|
|
99
|
+
profile: profile.id,
|
|
100
|
+
posture: security_posture.to_sym,
|
|
101
|
+
autonomy: autonomy,
|
|
102
|
+
protected: protected_paths.sort,
|
|
103
|
+
criteria: criteria.map { |c| [c.id, c.evaluator, c.run, c.required] }.sort_by(&:first),
|
|
104
|
+
waivers: waivers.map { |w| [w.target, w.expires&.iso8601] }.sort_by(&:first)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Materializes an {EffectiveContract} from the precedence stack in section
|
|
5
|
+
# 07. For this read-only slice the layers that participate are the product
|
|
6
|
+
# safety floor (non-bypassable), the repository contract (owner declarations
|
|
7
|
+
# in .reanimator.yml), and runtime discovery (detected commands and
|
|
8
|
+
# metadata, which may never authorize weaker behavior by itself). Higher
|
|
9
|
+
# organization/portfolio/family layers plug in at the same seam later.
|
|
10
|
+
#
|
|
11
|
+
# Two rules are enforced here, not merely documented:
|
|
12
|
+
# * an owner declaration wins over inference, but
|
|
13
|
+
# * neither an owner declaration nor discovery may loosen the safety floor.
|
|
14
|
+
class EffectivePolicyResolver
|
|
15
|
+
# v1 autonomy defaults (section 20): observe and diagnose on; repair and
|
|
16
|
+
# submit opt-in; merge off.
|
|
17
|
+
DEFAULT_AUTONOMY = {
|
|
18
|
+
observe: :automatic,
|
|
19
|
+
diagnose: :automatic,
|
|
20
|
+
repair: :disabled,
|
|
21
|
+
submit: :disabled,
|
|
22
|
+
merge: :disabled
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :repository, :discovery
|
|
26
|
+
|
|
27
|
+
def initialize(repository, discovery: nil)
|
|
28
|
+
@repository = repository
|
|
29
|
+
@discovery = discovery || Discovery.new(repository).run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [EffectiveContract]
|
|
33
|
+
def resolve
|
|
34
|
+
intent, intent_confirmed = resolve_intent
|
|
35
|
+
profile, profile_source = resolve_profile(intent)
|
|
36
|
+
autonomy, autonomy_notes = SafetyFloor.clamp_autonomy(resolve_autonomy)
|
|
37
|
+
posture = resolve_security_posture
|
|
38
|
+
|
|
39
|
+
EffectiveContract.new(
|
|
40
|
+
repository: repository,
|
|
41
|
+
intent: intent,
|
|
42
|
+
intent_confirmed: intent_confirmed,
|
|
43
|
+
profile: profile,
|
|
44
|
+
profile_source: profile_source,
|
|
45
|
+
criteria: CriteriaStack.new(
|
|
46
|
+
profile: profile, posture: posture, discovery: discovery, declared: declared
|
|
47
|
+
).resolve,
|
|
48
|
+
discovery: discovery,
|
|
49
|
+
security_posture: posture,
|
|
50
|
+
autonomy: autonomy,
|
|
51
|
+
protected_paths: SafetyFloor.protected_paths(declared_protected_paths),
|
|
52
|
+
waivers: build_waivers,
|
|
53
|
+
notes: autonomy_notes
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def declared = repository.declared
|
|
60
|
+
|
|
61
|
+
# Assigned intent wins, then a declared one, then a provisional suggestion
|
|
62
|
+
# from the classifier. Only the first two count as "confirmed".
|
|
63
|
+
def resolve_intent
|
|
64
|
+
if repository.intent
|
|
65
|
+
[LifecycleIntent.fetch(repository.intent), true]
|
|
66
|
+
elsif declared&.intent
|
|
67
|
+
[LifecycleIntent.fetch(declared.intent), true]
|
|
68
|
+
else
|
|
69
|
+
suggestion = Classifier.new(repository, discovery: discovery).classify
|
|
70
|
+
[LifecycleIntent.fetch(suggestion.suggested_intent), false]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A declared profile always wins over inference (section 06). When the
|
|
75
|
+
# inferred profile is rails-app but the intent is to preserve, the
|
|
76
|
+
# historical variant is the better fit.
|
|
77
|
+
def resolve_profile(intent)
|
|
78
|
+
if declared&.profile
|
|
79
|
+
[Profiles.fetch(declared.profile), :declared]
|
|
80
|
+
else
|
|
81
|
+
id = discovery.suggested_profile
|
|
82
|
+
id = :"rails-legacy" if id == :"rails-app" && intent.to_sym == :preserve
|
|
83
|
+
[Profiles.fetch(id), :inferred]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def declared_protected_paths
|
|
88
|
+
declared&.acceptance&.dig(:protected_paths) || []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Owner-declared waivers become accepted-risk exceptions on the contract.
|
|
92
|
+
def build_waivers
|
|
93
|
+
(declared&.waivers || []).map do |waiver|
|
|
94
|
+
Waiver.new(
|
|
95
|
+
target: waiver[:target] || waiver[:id],
|
|
96
|
+
reason: waiver[:reason].to_s,
|
|
97
|
+
owner: waiver[:owner] || "repository",
|
|
98
|
+
scope: waiver[:scope] || {},
|
|
99
|
+
expires: waiver[:expires]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def resolve_autonomy
|
|
105
|
+
declared_autonomy = declared&.autonomy || {}
|
|
106
|
+
DEFAULT_AUTONOMY.merge(declared_autonomy.transform_values(&:to_sym))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_security_posture
|
|
110
|
+
declared_posture = declared&.security&.dig(:posture)
|
|
111
|
+
return SecurityPosture[declared_posture] if declared_posture
|
|
112
|
+
|
|
113
|
+
SecurityPosture[Reanimator.configuration.security_posture]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Base class for every error the library raises, so consumers can rescue the
|
|
5
|
+
# whole family with a single `rescue Reanimator::Error`.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when a path is not a usable repository root.
|
|
9
|
+
class RepositoryError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a .reanimator.yml (or an inline contract) fails validation.
|
|
12
|
+
class ContractError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a requested profile, intent, or evaluator is not registered.
|
|
15
|
+
class UnknownError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when a policy layer attempts to loosen a constraint it may not
|
|
18
|
+
# loosen - the precedence rules in section 07 of the specification are not
|
|
19
|
+
# advisory, they are enforced.
|
|
20
|
+
class PolicyViolation < Error; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Shared plumbing for evaluators. Subclasses implement {#call} and return a
|
|
6
|
+
# {CriterionResult}. The evaluator sees the repository and discovery only
|
|
7
|
+
# through the read-only effective contract - it never receives, and cannot
|
|
8
|
+
# reach, a mutable agent workspace.
|
|
9
|
+
class Base
|
|
10
|
+
attr_reader :criterion, :contract, :execute, :timeout, :execution_root
|
|
11
|
+
|
|
12
|
+
def initialize(criterion, contract:, execute:, timeout:, execution_root: nil)
|
|
13
|
+
@criterion = criterion
|
|
14
|
+
@contract = contract
|
|
15
|
+
@execute = execute
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
@execution_root = execution_root || contract.repository.path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [CriterionResult]
|
|
21
|
+
def call
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def repository = contract.repository
|
|
28
|
+
|
|
29
|
+
def discovery = contract.discovery
|
|
30
|
+
|
|
31
|
+
def result(status, detail: nil, evidence: nil, duration: nil)
|
|
32
|
+
CriterionResult.new(
|
|
33
|
+
criterion: criterion, status: status, detail: detail, evidence: evidence, duration: duration
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Keep only the tail of noisy command output as retained evidence.
|
|
38
|
+
def tail(text, limit: 1_500)
|
|
39
|
+
stripped = text.to_s.strip
|
|
40
|
+
return stripped if stripped.length <= limit
|
|
41
|
+
|
|
42
|
+
"...#{stripped[-limit..]}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fallback for a criterion whose evaluator is not available in the
|
|
47
|
+
# read-only baseline (for example a hosted GitHub check). It is honest
|
|
48
|
+
# about the gap rather than guessing.
|
|
49
|
+
class Unsupported < Base
|
|
50
|
+
def call
|
|
51
|
+
result(:skipped, detail: "Evaluator #{criterion.evaluator.inspect} is not available in a read-only baseline.")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Runs a discovered or declared shell command and grades it by exit status.
|
|
6
|
+
# In the default (non-executing) mode it reports what it *would* run, so a
|
|
7
|
+
# user can see the plan before granting execution. When execution is
|
|
8
|
+
# enabled the command is bounded by a hard timeout.
|
|
9
|
+
class Command < Base
|
|
10
|
+
def call
|
|
11
|
+
return result(:missing_evidence, detail: "No command discovered for this check.") unless criterion.run
|
|
12
|
+
|
|
13
|
+
unless execute
|
|
14
|
+
return result(:planned, detail: "Would run: #{criterion.run}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
outcome = BoundedCommand.run(criterion.run, chdir: execution_root, timeout: timeout)
|
|
18
|
+
grade(outcome)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def grade(outcome)
|
|
24
|
+
if outcome.timed_out?
|
|
25
|
+
result(:failed, detail: "Timed out after #{timeout}s: #{criterion.run}", duration: outcome.duration)
|
|
26
|
+
elsif outcome.success?
|
|
27
|
+
result(:verified, detail: criterion.run, evidence: tail(outcome.output), duration: outcome.duration)
|
|
28
|
+
else
|
|
29
|
+
result(:failed, detail: "Nonzero exit: #{criterion.run}", evidence: tail(outcome.output), duration: outcome.duration)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# A native evaluator that checks for the presence of documentation the
|
|
6
|
+
# criterion names (README, LICENSE, or any target prefix). Cheap, read-only,
|
|
7
|
+
# and always runnable - it backs the "reproduction-documented" and
|
|
8
|
+
# "documented/licensed" criteria without executing anything.
|
|
9
|
+
class DocumentationCheck < Base
|
|
10
|
+
KNOWN = {"README" => "README*", "LICENSE" => "LICENSE*"}.freeze
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
target = criterion.target || "README"
|
|
14
|
+
pattern = KNOWN.fetch(target, "#{target}*")
|
|
15
|
+
matches = repository.glob(pattern)
|
|
16
|
+
|
|
17
|
+
if matches.any?
|
|
18
|
+
result(:verified, detail: "Found #{matches.first}.")
|
|
19
|
+
else
|
|
20
|
+
result(:missing_evidence, detail: "No #{target} found.")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# A boot smoke test against a health endpoint. Actually booting a service
|
|
6
|
+
# requires a running environment the read-only baseline deliberately does
|
|
7
|
+
# not provision, so this evaluator records the gap honestly - the criterion
|
|
8
|
+
# is a real, valuable proof-of-life check, it is simply not yet established.
|
|
9
|
+
# This is exactly the "production boot proof not established" gap Appendix A
|
|
10
|
+
# reports as Unproven rather than green.
|
|
11
|
+
class HttpSmoke < Base
|
|
12
|
+
def call
|
|
13
|
+
result(:missing_evidence,
|
|
14
|
+
detail: "Boot smoke against #{criterion.target || "the health endpoint"} needs a running " \
|
|
15
|
+
"service; not established in a read-only baseline.")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# A native evaluator for preserve/legacy repositories: attests that source
|
|
6
|
+
# remains readable with intact history. It only inspects git metadata, so
|
|
7
|
+
# it runs even in the non-executing baseline - a preserved artifact can be
|
|
8
|
+
# proven readable without running a single line of its code.
|
|
9
|
+
class RepositoryIntegrity < Base
|
|
10
|
+
def call
|
|
11
|
+
metadata = repository.metadata
|
|
12
|
+
if metadata.has_git?
|
|
13
|
+
result(:verified,
|
|
14
|
+
detail: "Git history is intact (#{metadata.commit_count || "unknown"} commits, " \
|
|
15
|
+
"last activity #{metadata.last_activity&.strftime("%Y-%m-%d") || "unknown"}).")
|
|
16
|
+
else
|
|
17
|
+
result(:missing_evidence, detail: "No git history found; source integrity cannot be attested.")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# A native evaluator that grades one security control from discovered
|
|
6
|
+
# evidence (specification section 12). It never asks a repository to adopt
|
|
7
|
+
# a particular tool - any of the control's accepted fingerprints counts -
|
|
8
|
+
# and a control with no evidence is reported as missing rather than
|
|
9
|
+
# quietly passed, which is the whole point of making the posture binding.
|
|
10
|
+
class SecurityEvidence < Base
|
|
11
|
+
def call
|
|
12
|
+
control = SecurityControl.fetch(criterion.target)
|
|
13
|
+
evidence = control.evidence_in(discovery)
|
|
14
|
+
return result(:missing_evidence, detail: control.remedy) unless evidence
|
|
15
|
+
|
|
16
|
+
result(:verified, detail: evidence.claim, evidence: evidence.provenance.to_s)
|
|
17
|
+
rescue UnknownError => e
|
|
18
|
+
# A contract naming a control we do not know is a misconfiguration, not
|
|
19
|
+
# a pass: say so, and let the unproven rules keep the repository honest.
|
|
20
|
+
result(:error, detail: e.message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Evaluators
|
|
5
|
+
# A native evaluator that compares declared runtime support against what CI
|
|
6
|
+
# actually exercises. This is the classic escalation from section 16: "the
|
|
7
|
+
# project declares Ruby 3.2 support, but CI no longer tests it." Declared
|
|
8
|
+
# support with no CI coverage is missing evidence, not a pass.
|
|
9
|
+
class SupportMatrix < Base
|
|
10
|
+
def call
|
|
11
|
+
ci = discovery[:ci_ruby_matrix]
|
|
12
|
+
declared = discovery[:supported_ruby]
|
|
13
|
+
|
|
14
|
+
if ci
|
|
15
|
+
result(:verified, detail: "CI exercises Ruby #{Array(ci.value).join(", ")}.")
|
|
16
|
+
elsif declared
|
|
17
|
+
result(:missing_evidence,
|
|
18
|
+
detail: "Ruby #{Array(declared.value).join(", ")} is declared but not exercised in CI.")
|
|
19
|
+
else
|
|
20
|
+
result(:skipped, detail: "No declared runtime support matrix to verify.")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Evaluators turn an acceptance criterion into a {CriterionResult}. They are
|
|
5
|
+
# the isolated grading half of section 09: the thing that decides pass or
|
|
6
|
+
# fail is deliberately separate from anything that could edit the repository.
|
|
7
|
+
#
|
|
8
|
+
# Two families live here. *Native* evaluators (repository integrity,
|
|
9
|
+
# documentation, support matrix, security evidence) read only inspection
|
|
10
|
+
# data and always run,
|
|
11
|
+
# even in the non-executing default - that is how a read-only baseline can
|
|
12
|
+
# still prove something. *Command* and *smoke* evaluators represent work that
|
|
13
|
+
# must actually execute, so they only run when a baseline is explicitly asked
|
|
14
|
+
# to execute.
|
|
15
|
+
module Evaluators
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param criterion [AcceptanceCriterion]
|
|
19
|
+
# @param contract [EffectiveContract]
|
|
20
|
+
# @param execute [Boolean]
|
|
21
|
+
# @param timeout [Integer]
|
|
22
|
+
# @param execution_root [String] directory commands run in - a disposable
|
|
23
|
+
# copy when executing, so the original working tree is never touched
|
|
24
|
+
# @return [Evaluators::Base]
|
|
25
|
+
def for(criterion, contract:, execute:, timeout:, execution_root: contract.repository.path)
|
|
26
|
+
klass = registry.fetch(criterion.evaluator, Unsupported)
|
|
27
|
+
klass.new(criterion, contract: contract, execute: execute, timeout: timeout, execution_root: execution_root)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def registry
|
|
31
|
+
{
|
|
32
|
+
command: Command,
|
|
33
|
+
http_smoke: HttpSmoke,
|
|
34
|
+
repository_integrity: RepositoryIntegrity,
|
|
35
|
+
documentation_check: DocumentationCheck,
|
|
36
|
+
support_matrix: SupportMatrix,
|
|
37
|
+
security_evidence: SecurityEvidence
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
class Family
|
|
5
|
+
# A cross-repository requirement (specification section 10 family policy):
|
|
6
|
+
# when the `on` member is a candidate for change, a check in another member
|
|
7
|
+
# must pass. Family policy may *add* evidence like this, but it can never
|
|
8
|
+
# weaken a member repository's own security constraints.
|
|
9
|
+
#
|
|
10
|
+
# A gate with no explicit check falls back to the downstream member's own
|
|
11
|
+
# proof command (a smoke script, else its discovered test command), so an
|
|
12
|
+
# inferred family is useful without anyone writing YAML.
|
|
13
|
+
class Gate
|
|
14
|
+
# @return [String] the member whose change triggers this gate
|
|
15
|
+
attr_reader :on
|
|
16
|
+
# @return [String] the member that must prove itself
|
|
17
|
+
attr_reader :requires_repository
|
|
18
|
+
# @return [String, nil] the command to run, or nil to use the member's own
|
|
19
|
+
attr_reader :check
|
|
20
|
+
|
|
21
|
+
def initialize(on:, requires_repository:, check: nil)
|
|
22
|
+
@on = on
|
|
23
|
+
@requires_repository = requires_repository
|
|
24
|
+
@check = check
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Whether the check command must be resolved from the downstream member.
|
|
29
|
+
def inferred_check? = check.nil?
|
|
30
|
+
|
|
31
|
+
def label
|
|
32
|
+
"#{on} requires #{check || "its own proof command"} in #{requires_repository}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{on: on, require: {repository: requires_repository, check: check}.compact}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
class Family
|
|
5
|
+
# The outcome of one cross-repository gate. Section 10 is explicit that
|
|
6
|
+
# "family failures identify the exact edge that broke, not merely the
|
|
7
|
+
# repository that ran CI", so a result carries the relationship it was
|
|
8
|
+
# proving, not just a repository name.
|
|
9
|
+
class GateResult
|
|
10
|
+
STATUSES = %i[verified failed missing_evidence planned skipped].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :gate, :relationship, :status, :detail, :duration
|
|
13
|
+
|
|
14
|
+
def initialize(gate:, status:, relationship: nil, detail: nil, duration: nil)
|
|
15
|
+
@gate = gate
|
|
16
|
+
@status = status.to_sym
|
|
17
|
+
unless STATUSES.include?(@status)
|
|
18
|
+
raise UnknownError, "Unknown gate result status #{status.inspect}."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@relationship = relationship
|
|
22
|
+
@detail = detail
|
|
23
|
+
@duration = duration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verified? = status == :verified
|
|
27
|
+
|
|
28
|
+
def failed? = status == :failed
|
|
29
|
+
|
|
30
|
+
# Not proven yet: nothing ran, or no downstream proof exists.
|
|
31
|
+
def unresolved? = %i[missing_evidence planned skipped].include?(status)
|
|
32
|
+
|
|
33
|
+
# The exact edge this result speaks for.
|
|
34
|
+
def edge
|
|
35
|
+
relationship&.label || "#{gate.on} -> #{gate.requires_repository}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
edge: edge, on: gate.on, requires: gate.requires_repository,
|
|
41
|
+
status: status, detail: detail, duration: duration
|
|
42
|
+
}.compact
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|