vaultkit 1.3.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17014258d6102f66c5d3479d541fbc2540f5f4b988c87c46c0bf8a7d17340043
4
- data.tar.gz: 83c4694cc2efd7bc579c2fbc29bf2f556fd22539ffae1a23f52ce741b55f126e
3
+ metadata.gz: e2eb23b9fcb727367f905e2d62f8b9de36e23cf9a00f54e067f92d6caff9b282
4
+ data.tar.gz: 8d125c6b810953abd5c0976fe18b2add1c65781431e9a3b2e147d6253fe968bb
5
5
  SHA512:
6
- metadata.gz: 452fad9cc3ed91570302342f4e319c26cf62c3446bb008f225719f01487731de5f953046ce67b8dbe0b33e6cad6406fa36d1b6e35cb1092a12bb7826e8999fa0
7
- data.tar.gz: c6ec74cacd3cc839ff3160413c86513f5f3a0d8a036f6a6bb938bfc56bc473215583dd9e63414c05c83710ba1c2d6d9a8c70dccd57fde6760e9f125058f6d34e
6
+ metadata.gz: f1780712e8c30407ad5b3319f0df166ba391deff62d79b5e89176867ca4a2d2e8e65b2ec8a4769f28ccd73133bb3e72a87bdf2de3c7ca37ecb79d54ed26ddfba
7
+ data.tar.gz: b6891fd707489ac9ad584459e1a89dcc9eefaa8eb1a73e19b5b93666bceb34c4cbd84246a1b67eede91453c9da94010f875611b35d09e4441dd11695ca93cdf1
data/README.md CHANGED
@@ -740,6 +740,26 @@ vkit scan production_pg
740
740
 
741
741
  To accept a suggestion, change the field's sensitivity and tags in `datasets/registry.yaml`, then build and deploy the bundle. Control planes that predate suggestions don't send them, and the output is unchanged.
742
742
 
743
+ **Unreviewed Columns:**
744
+
745
+ Columns the classifier couldn't place keep the `unknown` sensitivity until someone reviews them. The `starter` pack (2.1.0 and later) masks them for agents, so the scan lists them under their dataset, marked `!`, and counts them:
746
+
747
+ ```bash
748
+ vkit scan production_pg
749
+
750
+ # Output:
751
+ # customers ✓
752
+ # ! unreviewed: notes, meta
753
+ #
754
+ # No changes detected
755
+ #
756
+ # ⚠️ 2 columns are unreviewed and masked until you review them
757
+ # (for agents, under the starter pack). To review, set each one's sensitivity
758
+ # in datasets/registry.yaml, then run: vkit policy bundle && vkit policy deploy
759
+ ```
760
+
761
+ Older control planes don't send unreviewed columns, and the output is unchanged.
762
+
743
763
  **Apply Changes:**
744
764
  ```bash
745
765
  vkit scan production_pg --mode apply
@@ -299,11 +299,14 @@ module Vkit
299
299
  desc "upgrade [NAME]", "Upgrade installed policy packs (all if NAME omitted)"
300
300
  option :dir, type: :string, default: "."
301
301
  option :force, type: :boolean, default: false
302
+ option :restore, type: :boolean, default: false,
303
+ desc: "Restore an up-to-date pack's missing files without asking"
302
304
  def upgrade(name = nil)
303
305
  Commands::PolicyPackUpgradeCommand.new.call(
304
306
  pack_name: name,
305
307
  dir: options[:dir],
306
- force: options[:force]
308
+ force: options[:force],
309
+ restore: options[:restore]
307
310
  )
308
311
  end
309
312
  }
@@ -58,6 +58,7 @@ module Vkit
58
58
  puts " Version: #{bundle.dig("bundle", "bundle_version")}"
59
59
  puts " Checksum: #{bundle.dig("bundle", "checksum")}"
60
60
  puts " Output: #{out}"
61
+ print_policy_summary(bundle, policies_dir)
61
62
  end
62
63
  rescue Vkit::Policy::ValidationError => e
63
64
  puts e.message
@@ -66,6 +67,24 @@ module Vkit
66
67
 
67
68
  private
68
69
 
70
+ # Per-pack counts on every bundle, so a pack that contributes nothing
71
+ # is visible even when nothing errors.
72
+ def print_policy_summary(bundle, policies_dir)
73
+ total = bundle["policies"].length
74
+ packs = Array(bundle.dig("bundle", "installed_packs"))
75
+ from_packs = packs.sum { |p| Array(p["policy_ids"]).length }
76
+ rows = packs.map { |p| ["#{p["name"]} #{p["version"]}", Array(p["policy_ids"]).length] }
77
+ rows << ["repo (#{relative(policies_dir)})", total - from_packs]
78
+ width = rows.map { |label, _| label.length }.max
79
+
80
+ puts " Policies: #{total}"
81
+ rows.each { |label, count| puts " #{label.ljust(width)} #{count}" }
82
+ end
83
+
84
+ def relative(path)
85
+ path.delete_prefix(Dir.pwd + File::SEPARATOR)
86
+ end
87
+
69
88
  def git_sha
70
89
  out = `git rev-parse HEAD 2>/dev/null`.strip
71
90
  return nil if out.empty?
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "digest"
5
+ require_relative "../../policy/pack_integrity"
6
+ require_relative "../../policy/bundle_compiler"
4
7
 
5
8
  module Vkit
6
9
  module CLI
@@ -30,20 +33,50 @@ module Vkit
30
33
  org_slug = org || derived_org
31
34
 
32
35
  bundle = JSON.parse(File.read(bundle_path))
36
+ Vkit::Policy::PackIntegrity.check_bundle!(bundle)
33
37
 
34
- response = authenticated_client.post(
35
- "/api/v1/orgs/#{org_slug}/policy_bundles",
36
- body: {
37
- bundle: bundle,
38
- activate: activate
39
- }
40
- )
38
+ response = upload(org_slug, bundle, activate)
41
39
 
42
40
  puts "🚀 Policy bundle deployed"
43
41
  puts " Org: #{org_slug}"
44
42
  puts " Version: #{response['bundle_version']}"
45
43
  puts " State: #{response['state']}"
46
44
  end
45
+ rescue Vkit::Policy::ValidationError => e
46
+ puts e.message
47
+ exit 1
48
+ end
49
+
50
+ private
51
+
52
+ # A control plane from before vaultkitcli#47 doesn't know installed_packs'
53
+ # policy_ids and rejects the bundle on schema. The ids were checked
54
+ # above, so send the bundle without them (re-checksummed) instead.
55
+ def upload(org_slug, bundle, activate)
56
+ post_bundle(org_slug, bundle, activate)
57
+ rescue Vkit::CLI::API::APIError => e
58
+ raise unless e.status == 422 && e.error.to_s.match?(%r{installed_packs/\d+/policy_ids})
59
+
60
+ warn "ℹ️ This control plane predates per-pack policy checks; deploying without them."
61
+ post_bundle(org_slug, without_policy_ids(bundle), activate)
62
+ end
63
+
64
+ def post_bundle(org_slug, bundle, activate)
65
+ authenticated_client.post(
66
+ "/api/v1/orgs/#{org_slug}/policy_bundles",
67
+ body: {
68
+ bundle: bundle,
69
+ activate: activate
70
+ }
71
+ )
72
+ end
73
+
74
+ def without_policy_ids(bundle)
75
+ copy = JSON.parse(JSON.generate(bundle))
76
+ Array(copy.dig("bundle", "installed_packs")).each { |p| p.delete("policy_ids") }
77
+ copy["bundle"]["checksum"] = ""
78
+ copy["bundle"]["checksum"] = Digest::SHA256.hexdigest(Vkit::Policy::BundleCompiler.canonical_json(copy))
79
+ copy
47
80
  end
48
81
  end
49
82
  end
@@ -6,7 +6,9 @@ module Vkit
6
6
  module CLI
7
7
  module Commands
8
8
  class PolicyPackUpgradeCommand < BaseCommand
9
- def call(pack_name:, dir:, force: false)
9
+ # restore: rewrite an up-to-date pack's missing files without asking.
10
+ # Without it, a terminal is asked; anything else is told to rerun.
11
+ def call(pack_name:, dir:, force: false, restore: false, input: $stdin)
10
12
  ensure_project!(dir)
11
13
 
12
14
  manager = Vkit::CLI::PolicyPack::Manager.new(
@@ -22,8 +24,20 @@ module Vkit
22
24
  end
23
25
  end
24
26
 
27
+ results.each do |r|
28
+ restore_missing(manager, r, restore, input) if r[:status] == :missing
29
+ end
30
+
25
31
  print_summary(results)
26
32
 
33
+ if results.any? { |r| %i[failed missing].include?(r[:status]) }
34
+ if results.any? { |r| r[:status] == :missing }
35
+ puts "\nRestore them with 'vkit policy pack upgrade #{pack_name || "<pack>"} --restore'. " \
36
+ "Until then 'vkit policy bundle' fails."
37
+ end
38
+ exit 1
39
+ end
40
+
27
41
  puts "\nRun 'vkit policy bundle' to recompile."
28
42
  rescue StandardError => e
29
43
  puts "❌ Error: #{e.message}"
@@ -41,6 +55,8 @@ module Vkit
41
55
  def summarize(pack, result)
42
56
  if result == :up_to_date
43
57
  { name: pack, status: :up_to_date }
58
+ elsif result[:status] == :missing
59
+ { name: pack, status: :missing, version: result[:version], files: result[:files] }
44
60
  else
45
61
  {
46
62
  name: pack,
@@ -52,6 +68,26 @@ module Vkit
52
68
  end
53
69
  end
54
70
 
71
+ def restore_missing(manager, r, restore, input)
72
+ puts "⚠️ #{r[:name]} #{r[:version]} is installed, but #{r[:files].length} of its files are missing:"
73
+ r[:files].each { |f| puts " #{f["path"]} (#{f["policy_id"]})" }
74
+
75
+ return unless restore || confirm?(input, "Restore them from the pack? [Y/n] ")
76
+
77
+ manager.restore!(r[:name])
78
+ r[:status] = :restored
79
+ rescue => e
80
+ r.merge!(status: :failed, error: e.message)
81
+ end
82
+
83
+ def confirm?(input, prompt)
84
+ return false unless input.respond_to?(:tty?) && input.tty?
85
+
86
+ print prompt
87
+ answer = input.gets.to_s.strip.downcase
88
+ answer.empty? || answer.start_with?("y")
89
+ end
90
+
55
91
  def print_summary(results)
56
92
  return if results.empty?
57
93
 
@@ -68,6 +104,12 @@ module Vkit
68
104
  puts " To: v#{r[:new]}"
69
105
  puts " Policies updated: #{r[:policies]}"
70
106
 
107
+ when :restored
108
+ puts "✅ #{r[:name]}: restored #{r[:files].length} missing file(s) at v#{r[:version]}"
109
+
110
+ when :missing
111
+ puts "❌ #{r[:name]}: #{r[:files].length} file(s) missing, not restored"
112
+
71
113
  when :failed
72
114
  puts "❌ #{r[:name]}: #{r[:error]}"
73
115
  end
@@ -1,4 +1,6 @@
1
+ require "json"
1
2
  require_relative "../policy_bundle_validator"
3
+ require_relative "../../policy/pack_integrity"
2
4
 
3
5
  module Vkit
4
6
  module CLI
@@ -16,8 +18,12 @@ module Vkit
16
18
  )
17
19
 
18
20
  validator.validate!(bundle_path)
21
+ Vkit::Policy::PackIntegrity.check_bundle!(JSON.parse(File.read(bundle_path)))
19
22
 
20
23
  puts "✅ Policy bundle is valid"
24
+ rescue Vkit::Policy::ValidationError => e
25
+ puts e.message
26
+ exit 1
21
27
  end
22
28
 
23
29
  private
@@ -60,6 +60,7 @@ module Vkit
60
60
  end
61
61
 
62
62
  print_suggestions(dataset)
63
+ print_unreviewed(dataset)
63
64
 
64
65
  puts
65
66
  end
@@ -78,6 +79,8 @@ module Vkit
78
79
  puts " datasets/registry.yaml and redeploy the bundle."
79
80
  end
80
81
 
82
+ print_unreviewed_note(datasets)
83
+
81
84
  puts
82
85
  puts " ✅ Applied" if response["applied"]
83
86
  end
@@ -85,6 +88,28 @@ module Vkit
85
88
  def print_clean(dataset)
86
89
  puts " #{dataset["name"].ljust(24)} ✓"
87
90
  print_suggestions(dataset)
91
+ print_unreviewed(dataset)
92
+ end
93
+
94
+ # Columns still labelled unknown. Older control planes don't send them.
95
+ # The starter pack masks them for agents until someone reviews them.
96
+ def print_unreviewed(dataset)
97
+ names = unreviewed_for(dataset)
98
+ puts " ! unreviewed: #{names.join(", ")}" if names.any?
99
+ end
100
+
101
+ def print_unreviewed_note(datasets)
102
+ count = datasets.sum { |d| unreviewed_for(d).size }
103
+ return if count.zero?
104
+
105
+ puts
106
+ puts " ⚠️ #{count} #{count == 1 ? "column is" : "columns are"} unreviewed and masked until you review them"
107
+ puts " (for agents, under the starter pack). To review, set each one's sensitivity"
108
+ puts " in datasets/registry.yaml, then run: vkit policy bundle && vkit policy deploy"
109
+ end
110
+
111
+ def unreviewed_for(dataset)
112
+ Array(dataset["unreviewed"])
88
113
  end
89
114
 
90
115
  def print_suggestions(dataset)
@@ -185,10 +185,15 @@ module Vkit
185
185
  shipped_version = shipped_meta["version"]
186
186
 
187
187
  if installed_version == shipped_version
188
- return :up_to_date
188
+ missing = missing_files(pack_name)
189
+ return :up_to_date if missing.empty?
190
+
191
+ return { status: :missing, version: installed_version, files: missing }
189
192
  end
190
193
 
191
- remove!(pack_name, force: force) unless dry_run
194
+ # Every file is rewritten at the new version, so old ones that are
195
+ # already gone don't matter.
196
+ remove!(pack_name, force: true) unless dry_run
192
197
 
193
198
  count = install!(pack_name, force: force, dry_run: dry_run)
194
199
 
@@ -199,6 +204,39 @@ module Vkit
199
204
  }
200
205
  end
201
206
 
207
+ # Recorded files of an installed pack that are no longer on disk.
208
+ def missing_files(pack_name)
209
+ raise PackNotInstalled, "Pack '#{pack_name}' is not installed" unless installed?(pack_name)
210
+
211
+ Array(installed_packs[pack_name]["files"]).reject do |f|
212
+ File.exist?(File.expand_path(f.fetch("path"), @project_root))
213
+ end
214
+ end
215
+
216
+ # Rewrites an installed pack's missing files from the shipped pack,
217
+ # leaving the ones still present (and any edits to them) alone.
218
+ # Returns the restored file entries.
219
+ def restore!(pack_name)
220
+ meta, policies = read_pack!(pack_name)
221
+ installed_version = installed_packs.dig(pack_name, "version")
222
+
223
+ if installed_version != meta["version"]
224
+ raise Error, "Pack '#{pack_name}' #{installed_version} is installed but this CLI ships " \
225
+ "#{meta["version"]}; upgrade it instead"
226
+ end
227
+
228
+ by_id = policies.to_h { |p| [p.fetch("id"), p] }
229
+
230
+ missing_files(pack_name).each do |f|
231
+ policy = by_id[f["policy_id"]]
232
+ raise InvalidPack, "Pack '#{pack_name}' no longer ships policy '#{f["policy_id"]}'" unless policy
233
+
234
+ dest = File.expand_path(f.fetch("path"), @project_root)
235
+ FileUtils.mkdir_p(File.dirname(dest))
236
+ File.write(dest, render_policy_file(pack_name, meta, policy))
237
+ end
238
+ end
239
+
202
240
  def upgrade_all!(force: false, dry_run: false)
203
241
  # installed packs ordered by layer (low -> high)
204
242
  packs = installed_packs.keys.sort_by { |name| safe_pack_metadata(name)["layer"].to_i }
@@ -6,15 +6,18 @@ require_relative "policy_validator"
6
6
  require_relative "match_check"
7
7
  require_relative "tenancy_check"
8
8
  require_relative "validation_error"
9
+ require_relative "pack_integrity"
9
10
 
10
11
  module Vkit
11
12
  module Policy
12
13
  class BundleCompiler
13
14
  FORMAT_VERSION = "v1"
14
15
 
15
- # Raises if a policy can never match (see MatchCheck).
16
- def self.compile!(org_slug:, bundle_version:, policies_dir:, registry_dir:, source: {})
16
+ # Raises if a policy can never match (see MatchCheck), or if an
17
+ # installed pack is missing policies (see PackIntegrity).
18
+ def self.compile!(org_slug:, bundle_version:, policies_dir:, registry_dir:, source: {}, project_root: Dir.pwd)
17
19
  policies = load_policies(policies_dir)
20
+ installed_packs = PackIntegrity.check!(project_root: project_root, policies_dir: policies_dir, policies: policies)
18
21
  raw_registry = load_raw_registry(registry_dir)
19
22
  registry = normalize_registry(raw_registry)
20
23
  check_matches!(policies, registry, raw_registry)
@@ -33,7 +36,7 @@ module Vkit
33
36
  "signing" => nil
34
37
  }
35
38
 
36
- bundle["bundle"]["installed_packs"] = load_installed_packs
39
+ bundle["bundle"]["installed_packs"] = installed_packs
37
40
  canonical = canonical_json(bundle)
38
41
  bundle["bundle"]["checksum"] = Digest::SHA256.hexdigest(canonical)
39
42
 
@@ -51,7 +54,7 @@ module Vkit
51
54
 
52
55
  PolicyValidator.validate!(data, file: File.basename(f))
53
56
 
54
- data.merge("__file" => File.basename(f))
57
+ data.merge("__file" => File.basename(f), "__path" => File.expand_path(f))
55
58
  end
56
59
  end
57
60
 
@@ -212,23 +215,6 @@ module Vkit
212
215
  value
213
216
  end
214
217
  end
215
-
216
- def self.load_installed_packs
217
- tracking_path = File.join(".vkit", "packs.yaml")
218
- return [] unless File.exist?(tracking_path)
219
-
220
- data = YAML.safe_load(File.read(tracking_path), permitted_classes: [], permitted_symbols: [], aliases: true)
221
- packs = data["installed_packs"] || {}
222
-
223
- packs.map do |name, meta|
224
- {
225
- "name" => name,
226
- "version" => meta["version"]
227
- }
228
- end
229
- rescue
230
- []
231
- end
232
218
  end
233
219
  end
234
220
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "validation_error"
5
+
6
+ module Vkit
7
+ module Policy
8
+ # Checks that every pack recorded in .vkit/packs.yaml still contributes
9
+ # all of its policies, so a bundle can't quietly lose one (vaultkitcli#47).
10
+ module PackIntegrity
11
+ PACKS_DIR = File.expand_path("packs", __dir__)
12
+ TRACKING_PATH = File.join(".vkit", "packs.yaml")
13
+ VERSION_HEADER = /^# Pack Version: (.+)$/
14
+
15
+ module_function
16
+
17
+ # Installed packs as recorded, { name => entry }. A missing file means no
18
+ # packs; an unreadable one is an error, since it would hide every pack.
19
+ def installed_packs(project_root)
20
+ path = File.join(project_root, TRACKING_PATH)
21
+ return {} unless File.exist?(path)
22
+
23
+ data = YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: true) || {}
24
+ raise ValidationError, "#{TRACKING_PATH} is not a mapping" unless data.is_a?(Hash)
25
+
26
+ packs = data["installed_packs"] || {}
27
+ raise ValidationError, "#{TRACKING_PATH}: installed_packs is not a mapping" unless packs.is_a?(Hash)
28
+
29
+ packs
30
+ rescue Psych::Exception => e
31
+ raise ValidationError, "Can't read #{TRACKING_PATH}: #{e.message}"
32
+ end
33
+
34
+ # Raises unless each installed pack's recorded files are all present in
35
+ # policies_dir, at the recorded version, with the recorded policy ids.
36
+ # `policies` are the loaded policy files, each with "__path".
37
+ # Returns [{ "name", "version", "policy_ids" }] for the bundle.
38
+ def check!(project_root:, policies_dir:, policies:)
39
+ by_path = policies.to_h { |p| [real(p["__path"]), p] }
40
+ problems = []
41
+
42
+ packs = installed_packs(project_root).map do |name, entry|
43
+ entry ||= {}
44
+ version = entry["version"].to_s
45
+ files = Array(entry["files"])
46
+ pack_problems = []
47
+
48
+ pack_problems << "no files recorded in #{TRACKING_PATH}" if files.empty?
49
+
50
+ shipped = shipped_policy_ids(name, version)
51
+ if shipped && files.length < shipped.length
52
+ recorded = files.map { |f| f["policy_id"] }
53
+ pack_problems << "#{TRACKING_PATH} records #{files.length} of the pack's #{shipped.length} policies " \
54
+ "(not recorded: #{(shipped - recorded).join(", ")})"
55
+ end
56
+
57
+ files.each do |f|
58
+ rel = f["path"].to_s
59
+ abs = File.expand_path(rel, project_root)
60
+ expected_id = f["policy_id"].to_s
61
+
62
+ unless File.exist?(abs)
63
+ pack_problems << "missing #{rel} (#{expected_id})"
64
+ next
65
+ end
66
+
67
+ policy = by_path[real(abs)]
68
+ unless policy
69
+ pack_problems << "#{rel} (#{expected_id}) is not in the policies directory #{policies_dir}"
70
+ next
71
+ end
72
+
73
+ if policy["id"].to_s != expected_id
74
+ pack_problems << "#{rel} has policy id #{policy["id"].inspect}, recorded as #{expected_id.inspect}"
75
+ end
76
+
77
+ file_version = File.read(abs)[VERSION_HEADER, 1]&.strip&.delete('"')
78
+ if file_version && file_version != version
79
+ pack_problems << "#{rel} is from version #{file_version}, but #{version} is installed"
80
+ end
81
+ end
82
+
83
+ problems << [name, version, pack_problems] if pack_problems.any?
84
+
85
+ { "name" => name.to_s, "version" => version, "policy_ids" => files.map { |f| f["policy_id"].to_s } }
86
+ end
87
+
88
+ raise ValidationError, problems_message(problems, restore: true) if problems.any?
89
+
90
+ packs
91
+ end
92
+
93
+ # Raises unless every policy a bundle's installed_packs lists is in the
94
+ # bundle. Bundles from vkit 1.3.0 and older record only name and
95
+ # version: those are checked against the pack this CLI ships, when it
96
+ # ships that version.
97
+ def check_bundle!(bundle)
98
+ bundled = Array(bundle["policies"]).map { |p| p["id"].to_s }
99
+ problems = []
100
+
101
+ Array(bundle.dig("bundle", "installed_packs")).each do |pack|
102
+ name = pack["name"].to_s
103
+ version = pack["version"].to_s
104
+ expected = pack["policy_ids"] || shipped_policy_ids(name, version)
105
+ next unless expected
106
+
107
+ missing = expected - bundled
108
+ problems << [name, version, ["missing policies: #{missing.join(", ")}"]] if missing.any?
109
+ end
110
+
111
+ raise ValidationError, problems_message(problems, restore: false) if problems.any?
112
+ end
113
+
114
+ def real(path)
115
+ File.realpath(path)
116
+ rescue SystemCallError
117
+ path
118
+ end
119
+
120
+ # Policy ids of the pack this CLI ships, or nil if it ships another version.
121
+ def shipped_policy_ids(name, version)
122
+ root = File.join(PACKS_DIR, name.to_s)
123
+ meta_path = File.join(root, "metadata.yaml")
124
+ return unless File.exist?(meta_path)
125
+
126
+ meta = (YAML.safe_load(File.read(meta_path), aliases: true) || {})["__pack_meta"] || {}
127
+ return unless meta["version"].to_s == version.to_s
128
+
129
+ Dir.glob(File.join(root, "policies", "*.yaml")).sort.map do |file|
130
+ (YAML.safe_load(File.read(file), aliases: true) || {})["id"].to_s
131
+ end
132
+ end
133
+
134
+ def problems_message(problems, restore:)
135
+ lines = problems.map do |name, version, list|
136
+ " #{name} #{version}:\n#{list.map { |p| " - #{p}" }.join("\n")}"
137
+ end
138
+
139
+ hint =
140
+ if restore
141
+ "Restore the files with `vkit policy pack upgrade <pack>`, or remove the pack with " \
142
+ "`vkit policy pack remove <pack> --force` if you no longer want it."
143
+ else
144
+ "Rebuild the bundle with `vkit policy bundle`, which checks the installed packs."
145
+ end
146
+
147
+ <<~MSG
148
+ ❌ Installed policy packs are incomplete; the bundle would leave out their policies:
149
+
150
+ #{lines.join("\n")}
151
+
152
+ #{hint}
153
+ MSG
154
+ end
155
+ end
156
+ end
157
+ end
@@ -1,8 +1,8 @@
1
1
  __pack_meta:
2
2
  name: starter
3
- version: "2.0.0"
3
+ version: "2.1.0"
4
4
  layer: 10
5
- description: "Secure defaults: secrets denied, PII and high-sensitivity fields masked, approval for high-sensitivity data in production, unreviewed columns flagged."
5
+ description: "Secure defaults: secrets denied, PII and high-sensitivity fields masked, approval for high-sensitivity data in production, unreviewed columns flagged, and masked for agents."
6
6
  dependencies: []
7
7
  priority_band:
8
8
  min: 0
@@ -1,5 +1,5 @@
1
1
  id: flag_unknown_sensitivity
2
- description: Allow columns nobody has reviewed yet, and record why, so they show up in the audit log.
2
+ description: Allow people to read columns nobody has reviewed yet, and record why, so they show up in the audit log. Agents get them masked (mask_unknown_for_agents).
3
3
  priority: 20
4
4
 
5
5
  match:
@@ -0,0 +1,18 @@
1
+ id: mask_unknown_for_agents
2
+ description: Mask columns nobody has reviewed yet when an agent reads them, in every environment, until someone gives them a real label.
3
+ priority: 84
4
+
5
+ match:
6
+ fields:
7
+ sensitivity: unknown
8
+
9
+ context:
10
+ requester_role: agent
11
+
12
+ action:
13
+ mask: true
14
+ reason: This column hasn't been reviewed, so it's masked for agents. Give it a sensitivity in datasets/registry.yaml and redeploy the bundle.
15
+ ttl: 1h
16
+
17
+ masking:
18
+ default_method: full
@@ -78,7 +78,13 @@
78
78
  "additionalProperties": false,
79
79
  "properties": {
80
80
  "name": { "type": "string" },
81
- "version": { "type": "string" }
81
+ "version": { "type": "string" },
82
+ "policy_ids": {
83
+ "type": "array",
84
+ "minItems": 1,
85
+ "description": "Ids of the policies this pack contributes. Every one must be in policies.",
86
+ "items": { "type": "string", "minLength": 1 }
87
+ }
82
88
  }
83
89
  }
84
90
  }
data/lib/vkit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vkit
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vaultkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nnamdi Ogundu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-05 00:00:00.000000000 Z
11
+ date: 2026-11-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -96,6 +96,7 @@ files:
96
96
  - lib/vkit/core/table_formatter.rb
97
97
  - lib/vkit/policy/bundle_compiler.rb
98
98
  - lib/vkit/policy/match_check.rb
99
+ - lib/vkit/policy/pack_integrity.rb
99
100
  - lib/vkit/policy/packs/ai_safety/metadata.yaml
100
101
  - lib/vkit/policy/packs/ai_safety/policies/01_require_approval_agents_sensitive_prod.yaml
101
102
  - lib/vkit/policy/packs/ai_safety/policies/02_require_approval_agents_high_sensitivity.yaml
@@ -110,6 +111,7 @@ files:
110
111
  - lib/vkit/policy/packs/starter/policies/04_require_approval_high_sensitivity.yaml
111
112
  - lib/vkit/policy/packs/starter/policies/05_default_ttl.yaml
112
113
  - lib/vkit/policy/packs/starter/policies/06_flag_unknown.yaml
114
+ - lib/vkit/policy/packs/starter/policies/07_mask_unknown_for_agents.yaml
113
115
  - lib/vkit/policy/policy_validator.rb
114
116
  - lib/vkit/policy/schema/policy_bundle.schema.json
115
117
  - lib/vkit/policy/tenancy_check.rb