hecks 3.1.2 → 3.1.3

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: 508adf3c37fdb512fe3fa7ba61d7f614742a76b750f0072a363bec69bc99cefd
4
- data.tar.gz: 825f357d3d68ccab74ba071fc4ab3abc412ffc38458968d9f038a769e2039382
3
+ metadata.gz: 88b11874249737158a9451d66aa0842ef88fb3f1ff047cced68d97b2ce9fdc5f
4
+ data.tar.gz: 2872e381ce8e7fe5d46ef7a7623134dc23d923e57bdf194758effedaa1cc4671
5
5
  SHA512:
6
- metadata.gz: e55bd4e1b03b8399b7dc189bee0cd126a00ec315eed8c1060cd0dd237c443c61af26d6005af246c1855f54f683d710646bf7b05fbb068335c14fbb01eb5cdcef
7
- data.tar.gz: 300db7e2fd71722c2627fa1bcc96c992776d87e796db3cdc83570eb8b48f2200c05def67bea1f116a30506c613f69e44a4573d36f3e36fd5bc394687b694855f
6
+ metadata.gz: 76a39ab361adfb4e07bb558cb917d46bf3d7b6bb21340ff9789cb7b6c10be47a3197df4f394e8da73129feeb5b8439d9401f5e83b8620e0a512fce966c891377
7
+ data.tar.gz: 58f1514450a234f3807897401daf7db410739463a8603bf58b7d73e548f14424211b4d1c5f3af1dc7421b769f39582e15c6f22188932af3de054961eed2cb641
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Driving adapter standing in for a Stripe webhook handler: boots Pizzas and calls
4
+ # Dispatcher#dispatch_port, which translates the payment into PizzaPaymentReceived.
5
+ #
6
+ # examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
7
+
8
+ $LOAD_PATH.unshift File.expand_path("../../../../lib", __dir__)
9
+
10
+ require "hecks"
11
+
12
+ DOMAIN = File.expand_path("../..", __dir__)
13
+ RUNTIME = Hecks.boot(DOMAIN)
14
+
15
+ # Unique per run: identity is never minted, so a repeated name would be refused as AlreadyExists.
16
+ NAME = "StripeDemoMargherita-#{Process.pid}-#{rand(10_000)}".freeze
17
+
18
+ # Set the stage: an order Purchase's `given` rules will accept.
19
+ order = Order.create_pizza(name: { value: NAME }, pizza: { price_cents: { cents: 1200 }, size: { value: "large" } })
20
+ order.add_topping(topping: { value: "Basil" }, amount: { value: 3 })
21
+
22
+ puts "Before payment: #{order.status}, customer=#{order.customer_name.inspect}"
23
+
24
+ # The webhook: a real handler would verify a Stripe signature; a reference is always a bare id.
25
+ RUNTIME.dispatch_port(
26
+ "Pizzas", "Order", "PaymentGateway", "Receive",
27
+ flat: {
28
+ name: NAME,
29
+ customer_name: { value: "Chris" },
30
+ amount: { cents: 1200 }
31
+ }
32
+ )
33
+
34
+ sold = Order.find(NAME)
35
+ puts "After payment: #{sold.status}, customer=#{sold.customer_name.to_h}"
36
+ puts "Events: #{sold.events.map(&:name).join(', ')}"
@@ -0,0 +1,70 @@
1
+ Hecks.behaviors "Pizzas" do
2
+ vision "Examples of how a pizza is built, sold, and found through this domain."
3
+
4
+ # `pizzas_behaviors.hecksagon` is a Memory-persisted sibling of
5
+ # `pizzas.hecksagon` that lives OUTSIDE bluebook/, so an ordinary
6
+ # directory boot of examples/pizzas never globs it up alongside the
7
+ # real one.
8
+ loads "pizzas.bluebook", "../pizzas_behaviors.hecksagon"
9
+
10
+ test "CreatePizza puts a fresh pizza on the menu, available for sale" do
11
+ tests "CreatePizza", on: "Order"
12
+ input name: "Margherita", pizza: { price_cents: { cents: 1200 }, size: "large" }
13
+ expect status: "available", customer_name: nil
14
+ end
15
+
16
+ test "AddTopping refuses more than ten toppings" do
17
+ tests "AddTopping", on: "Order"
18
+ setup "CreatePizza", name: "Loaded", pizza: { price_cents: { cents: 1500 }, size: "large" }
19
+ setup "AddTopping", name: "Loaded", topping: "Basil", amount: 1
20
+ setup "AddTopping", name: "Loaded", topping: "Olives", amount: 1
21
+ setup "AddTopping", name: "Loaded", topping: "Mushroom", amount: 1
22
+ setup "AddTopping", name: "Loaded", topping: "Onion", amount: 1
23
+ setup "AddTopping", name: "Loaded", topping: "Pepper", amount: 1
24
+ setup "AddTopping", name: "Loaded", topping: "Sausage", amount: 1
25
+ setup "AddTopping", name: "Loaded", topping: "Bacon", amount: 1
26
+ setup "AddTopping", name: "Loaded", topping: "Pineapple", amount: 1
27
+ setup "AddTopping", name: "Loaded", topping: "Anchovy", amount: 1
28
+ setup "AddTopping", name: "Loaded", topping: "Jalapeno", amount: 1
29
+ input name: "Loaded", topping: "Extra Cheese", amount: 1
30
+ expect refused: "at most 10 toppings"
31
+ end
32
+
33
+ test "Purchase refuses a pizza with no toppings on it" do
34
+ tests "Purchase", on: "Order"
35
+ setup "CreatePizza", name: "Bare", pizza: { price_cents: { cents: 900 }, size: "small" }
36
+ input name: "Bare", customer_name: "Chris", amount: { cents: 900 }
37
+ expect refused: "at least one topping"
38
+ end
39
+
40
+ test "Purchase sells the pizza and closes its lifecycle" do
41
+ tests "Purchase", on: "Order"
42
+ setup "CreatePizza", name: "Sold Out", pizza: { price_cents: { cents: 1200 }, size: "large" }
43
+ setup "AddTopping", name: "Sold Out", topping: "Basil", amount: 2
44
+ input name: "Sold Out", customer_name: "Chris", amount: { cents: 1200 }
45
+ expect emits: ["PizzaPurchased"]
46
+ end
47
+
48
+ test "a sold pizza is no longer Available" do
49
+ tests "Available", on: "Order", kind: :query
50
+ setup "CreatePizza", name: "Fresh", pizza: { price_cents: { cents: 1000 }, size: "small" }
51
+ setup "CreatePizza", name: "Also Sold", pizza: { price_cents: { cents: 1100 }, size: "small" }
52
+ setup "AddTopping", name: "Also Sold", topping: "Basil", amount: 1
53
+ setup "Purchase", name: "Also Sold", customer_name: "Chris", amount: { cents: 1100 }
54
+ expect count: 1
55
+ end
56
+
57
+ test "CostingLessThan finds a pizza under the ceiling named" do
58
+ tests "CostingLessThan", on: "Order", kind: :query
59
+ setup "CreatePizza", name: "Bargain", pizza: { price_cents: { cents: 800 }, size: "small" }
60
+ setup "CreatePizza", name: "Splurge", pizza: { price_cents: { cents: 1800 }, size: "large" }
61
+ input ceiling: { cents: 1000 }
62
+ expect count: 1
63
+ end
64
+
65
+ test "Available's own row can be checked directly, not just counted" do
66
+ tests "Available", on: "Order", kind: :query
67
+ setup "CreatePizza", name: "Only One", pizza: { price_cents: { cents: 950 }, size: "small" }
68
+ expect status: "available", name: { value: "Only One" }
69
+ end
70
+ end
@@ -0,0 +1,138 @@
1
+ Hecks.bluebook "Pizzas" do
2
+ vision "Put toppings on a pizza and sell it to a customer."
3
+ supporting
4
+
5
+ aggregate "Order" do
6
+ description "An order that gathers toppings on a pizza and is eventually sold to a customer."
7
+
8
+ attribute :name, PizzaName
9
+ attribute :pizza, Pizza
10
+ attribute :toppings, list_of(Topping)
11
+ # Optional: unknown until `Purchase` sets it.
12
+ attribute :customer_name, CustomerName, optional: true
13
+
14
+ identified_by :name
15
+
16
+ value_object "PizzaName" do
17
+ attribute :value, String, pattern: '[^ \t\n\r]'
18
+
19
+ invariant("a pizza is named") { !value.to_s.empty? }
20
+ end
21
+
22
+ value_object "Price" do
23
+ attribute :cents, Integer
24
+
25
+ invariant("a price is never negative") { cents >= 0 }
26
+ end
27
+
28
+ value_object "CustomerName" do
29
+ attribute :value, String, pattern: '[^ \t\n\r]'
30
+
31
+ invariant("a customer is named") { !value.to_s.empty? }
32
+ end
33
+
34
+ value_object "ToppingName" do
35
+ attribute :value, String, pattern: '[^ \t\n\r]'
36
+
37
+ invariant("a topping is named") { !value.to_s.empty? }
38
+ end
39
+
40
+ value_object "ToppingAmount" do
41
+ attribute :value, Integer
42
+
43
+ invariant("an amount is positive") { value.positive? }
44
+ end
45
+
46
+ value_object "Topping" do
47
+ attribute :name, String, pattern: '[^ \t\n\r]'
48
+ attribute :amount, Integer
49
+ end
50
+
51
+ # A named, reusable closed set as a sibling value object; the inline `one_of(...)` shorthand
52
+ # cannot be spelled inside a `value_object` block.
53
+ value_object "Size" do
54
+ attribute :value, String, one_of: ["small", "large"]
55
+ end
56
+
57
+ # What was ordered; who ordered it and payment state live on Order.
58
+ value_object "Pizza" do
59
+ attribute :price_cents, Price
60
+ attribute :size, Size
61
+ end
62
+
63
+ lifecycle :status, default: "available" do
64
+ transition "Purchase" => "sold", from: "available"
65
+ end
66
+
67
+ query "Available" do
68
+ where(status: "available")
69
+ order_by :name
70
+ end
71
+
72
+ # Both price queries reach through the nested Pizza value object with a dotted path.
73
+ query "CostingLessThan" do
74
+ attribute :ceiling, Price
75
+ where(:"pizza.price_cents.cents" => { lt: :ceiling })
76
+ order_by :name
77
+ end
78
+
79
+ # A literal, not a kwarg: exercises the path where the where-clause number is stringified.
80
+ query "Expensive" do
81
+ where(:"pizza.price_cents.cents" => { gt: 1000 })
82
+ order_by :name
83
+ end
84
+
85
+ command "CreatePizza" do
86
+ role "Chef"
87
+ goal "Put a new pizza on the menu"
88
+
89
+ attribute :name, PizzaName
90
+ attribute :pizza, Pizza
91
+
92
+ sets :name
93
+ sets :pizza
94
+
95
+ emits PizzaCreated
96
+ end
97
+
98
+ # AddTopping has no transition, so `from:` is its only lifecycle guard.
99
+ command "AddTopping", from: "available" do
100
+ role "Chef"
101
+ goal "Customize a pizza with an ingredient"
102
+
103
+ reference_to Order
104
+ attribute :topping, ToppingName
105
+ attribute :amount, ToppingAmount
106
+
107
+ given("at most 10 toppings") { toppings.size < 10 }
108
+
109
+ sets :toppings, append: { name: :topping, amount: :amount }
110
+
111
+ emits ToppingAdded
112
+ end
113
+
114
+ command "Purchase", from: "available" do
115
+ role "Customer"
116
+ goal "Buy the pizza"
117
+
118
+ reference_to Order
119
+ attribute :amount, Price
120
+
121
+ given("a pizza needs at least one topping") { toppings.size.positive? }
122
+ given("a payment was actually made") { amount.cents.positive? }
123
+ # Checks the payment itself is plausible (e.g. an underpayment), not whether one happened.
124
+ given("the payment covers the price") { amount.cents >= pizza.price_cents.cents }
125
+
126
+ # The lifecycle transition sets status; a `sets` on the lifecycle field is refused at build.
127
+ sets :customer_name
128
+
129
+ emits PizzaPurchased
130
+ end
131
+ end
132
+
133
+ # A payment arriving is an external fact; the business rules stay on Purchase.
134
+ policy "OnPizzaPaymentReceived" do
135
+ on Order::PizzaPaymentReceived
136
+ trigger Order::Purchase
137
+ end
138
+ end
@@ -0,0 +1,21 @@
1
+ Hecks.hecksagon "Pizzas" do
2
+ # Role declarations only enforce access once Governance can check them; this standalone
3
+ # deploy attaches and binds Governance itself.
4
+ attaches "Governance"
5
+ Pizzas::Order.persisted_by("Memory")
6
+
7
+ # The primary port: an outside adapter (a payment webhook) translates an external fact into
8
+ # our vocabulary; business rules stay on Purchase, reached via OnPizzaPaymentReceived.
9
+ Pizzas::Order.port "PaymentGateway" do
10
+ operation "Receive" do
11
+ attribute :customer_name, CustomerName
12
+ attribute :amount, Price
13
+ emits "PizzaPaymentReceived"
14
+ end
15
+ end
16
+ end
17
+
18
+ Hecks.hecksagon "Governance" do
19
+ Governance::RoleAssignment.persisted_by("Memory")
20
+ Governance::RoleTransition.persisted_by("Memory")
21
+ end
@@ -0,0 +1,3 @@
1
+ Hecks.world "Pizzas" do
2
+ realm "Examples"
3
+ end
@@ -0,0 +1,6 @@
1
+ Hecks.data_translation "Pizzas", from: "e3f3d7", to: "77625c" do
2
+ aggregate "Order", was: "Pizza" do
3
+ move :price_cents, to: "pizza.price_cents"
4
+ move :size, to: "pizza.size"
5
+ end
6
+ end
@@ -0,0 +1,24 @@
1
+ {
2
+ "audience": "Pizzas",
3
+ "scopes": [
4
+ {
5
+ "scope": "pizzas:order.add_topping",
6
+ "verb": "Pizzas::Order.AddTopping",
7
+ "role": "Chef"
8
+ },
9
+ {
10
+ "scope": "pizzas:order.create_pizza",
11
+ "verb": "Pizzas::Order.CreatePizza",
12
+ "role": "Chef"
13
+ },
14
+ {
15
+ "scope": "pizzas:order.purchase",
16
+ "verb": "Pizzas::Order.Purchase",
17
+ "role": "Customer"
18
+ }
19
+ ],
20
+ "roles": [
21
+ "Chef",
22
+ "Customer"
23
+ ]
24
+ }
@@ -0,0 +1,19 @@
1
+ # Memory-bound sibling of pizzas.hecksagon: `pizzas.behaviors` boots a fresh runtime per
2
+ # test, so it loads this sibling instead of the one beside the bluebook.
3
+ Hecks.hecksagon "Pizzas" do
4
+ attaches "Governance"
5
+ Pizzas::Order.persisted_by("Memory")
6
+
7
+ Pizzas::Order.port "PaymentGateway" do
8
+ operation "Receive" do
9
+ attribute :customer_name, CustomerName
10
+ attribute :amount, Price
11
+ emits "PizzaPaymentReceived"
12
+ end
13
+ end
14
+ end
15
+
16
+ Hecks.hecksagon "Governance" do
17
+ Governance::RoleAssignment.persisted_by("Memory")
18
+ Governance::RoleTransition.persisted_by("Memory")
19
+ end
@@ -47,12 +47,15 @@ module Hecks
47
47
  end
48
48
 
49
49
  # @param runtime [Runtime] a booted domain
50
- # @return [String] each chapter's vision and its aggregates' commands
50
+ # @return [String] each chapter's vision, and its aggregates' descriptions and commands
51
51
  def overview(runtime)
52
52
  runtime.registry.bluebooks.each_value.map do |bluebook|
53
53
  aggregates = bluebook.aggregates.map do |aggregate|
54
54
  commands = aggregate.commands.map { |command| "#{Naming.snake(command.hecks_name)}!" }.sort.join(", ")
55
- " #{aggregate.name}: #{commands}"
55
+ description = aggregate.description.to_s.strip
56
+ lines = [" #{aggregate.name}: #{commands}"]
57
+ lines << " #{description}" unless description.empty?
58
+ lines.join("\n")
56
59
  end
57
60
 
58
61
  "\n #{bluebook.name} — #{bluebook.vision}\n#{aggregates.join("\n")}\n"
@@ -120,6 +120,7 @@ module Hecks
120
120
 
121
121
  name = argv.first
122
122
  return { answer: [cli[:usage], 0] } if name.nil? || %w[--help -h help].include?(name)
123
+ return { answer: [all_usage(bluebook, options), 0] } if name == "--all"
123
124
 
124
125
  asking, name, argv = entry_word(cli, argv)
125
126
  return { answer: [cli[:usage], 1] } if name.nil?
@@ -141,6 +142,11 @@ module Hecks
141
142
  launcher: launcher }
142
143
  end
143
144
 
145
+ # The usage with the internal commands and queries listed too, which `--all` asks for.
146
+ def all_usage(bluebook, options)
147
+ Projector.call(:cli, bluebook: bluebook, options: options.merge(all: true))[:usage]
148
+ end
149
+
144
150
  # Reads the entry words of a line: whether it asks a query, the bare name, and the words
145
151
  # from the name on.
146
152
  #
@@ -403,41 +403,70 @@ module Hecks
403
403
  out = ["#{bluebook.name} — #{bluebook.vision}", "",
404
404
  " #{program} <command>! [name=value …] do something",
405
405
  " #{program} query <query> [name=value …] read something", ""]
406
-
407
- out << "commands:"
408
- out.concat(listing(commands) { |spec| spec[:summary] })
409
- out << ""
410
- out << "queries (nothing here changes anything):"
411
- out.concat(listing(questions) { |spec| first_sentence(spec[:summary]) })
406
+ out.concat(tables(commands, questions, all: options[:all], notes: aggregate_notes(bluebook)))
412
407
  out << ""
413
408
  out << " #{program} <command> --help what one command wants, and every way it refuses"
409
+ out.concat(all_hint(program, commands, questions)) unless options[:all]
414
410
  out << " a command is called with its aggregate — #{example_qualified(commands)}"
415
411
  out.join("\n")
416
412
  end
417
413
 
414
+ # The commands table then the queries table.
415
+ def tables(commands, questions, all:, notes:)
416
+ ["commands:", *listing(commands, all: all, notes: notes) { |spec| spec[:summary] }, "",
417
+ "queries (nothing here changes anything):",
418
+ *listing(questions, all: all, notes: notes) { |spec| first_sentence(spec[:summary]) }]
419
+ end
420
+
421
+ # The line saying the internal commands and queries were left out and how to list them,
422
+ # or no line when there are none.
423
+ def all_hint(program, commands, questions)
424
+ hidden = (commands.values + questions.values).count { |spec| spec[:internal] }
425
+ return [] if hidden.zero?
426
+
427
+ [" #{program} --all also list the #{hidden} internal commands and queries"]
428
+ end
429
+
418
430
  # The command or question lines of the help. A domain with more than one aggregate is listed
419
431
  # under a heading per aggregate, so related commands sit together; the heading is the prefix
420
432
  # every call to them carries, so the lines under it leave it out. The bookkeeping a run
421
- # records about itself (`internal`: system-role commands and port operations) is set apart as
422
- # names only, since a person never types them. A single-aggregate domain keeps the plain
423
- # list, each name in full.
424
- def listing(specs)
433
+ # records about itself (`internal`: system-role commands and port operations) is left out,
434
+ # since a person never types them; `all` lists them as names only. A single-aggregate
435
+ # domain keeps the plain list, each name in full.
436
+ #
437
+ # @param notes [Hash{String => String}] a line of prose per aggregate, shown under its heading
438
+ def listing(specs, all: false, notes: {}, &description)
425
439
  shown, internal = specs.values.partition { |spec| !spec[:internal] }
426
- groups = shown.group_by { |spec| spec[:group] }
427
- grouped = groups.length > 1
440
+ grouped = shown.map { |spec| spec[:group] }.uniq.length > 1
428
441
  named = shown.to_h { |spec| [spec, entry_name(spec, grouped)] }
442
+ lines = listed_rows(shown, named, grouped, notes, &description)
443
+ lines.concat(internal_lines(internal)) if all && !internal.empty?
444
+ lines
445
+ end
446
+
447
+ # The rows of one table: one line per spec, under a heading per aggregate when grouped.
448
+ def listed_rows(shown, named, grouped, notes, &description)
429
449
  width = named.values.map(&:length).max.to_i
430
- lines = []
431
- if grouped
432
- groups.each do |group, members|
433
- lines << " #{heading(group)}" if group
434
- members.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
435
- end
436
- else
437
- shown.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
450
+ row = ->(spec, indent) { "#{indent}#{named[spec].ljust(width)} #{description.call(spec)}#{alias_note(spec)}" }
451
+ return shown.map { |spec| row.call(spec, " ") } unless grouped
452
+
453
+ shown.group_by { |spec| spec[:group] }.flat_map do |group, members|
454
+ [*heading_lines(group, notes), *members.map { |spec| row.call(spec, " ") }]
438
455
  end
439
- lines.concat(internal_lines(internal)) unless internal.empty?
440
- lines
456
+ end
457
+
458
+ # The lines that open an aggregate's group: its heading, then its note when it has one.
459
+ def heading_lines(group, notes)
460
+ return [] unless group
461
+
462
+ [" #{heading(group)}", *notes[group]&.then { |note| " #{note}" }]
463
+ end
464
+
465
+ # The first sentence of each aggregate's description, keyed by aggregate name; an aggregate
466
+ # with no description has no entry.
467
+ def aggregate_notes(bluebook)
468
+ bluebook.aggregates.to_h { |aggregate| [aggregate.hecks_name, first_sentence(aggregate.description)] }
469
+ .reject { |_, note| note.empty? }
441
470
  end
442
471
 
443
472
  # The aggregate's own name as a heading: the prefix of every call to the lines under it.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "shellwords"
5
+ require_relative "agent_profile"
5
6
 
6
7
  module Hecks
7
8
  module Adapters
@@ -27,10 +28,11 @@ module Hecks
27
28
  # The command an ask runs.
28
29
  #
29
30
  # @param command [String, Array<String>, nil] a command line, or its words
30
- # @return [Array<String>] `command` as words; `QA_MINER_AGENT` else `DEFAULT_COMMAND` when nil
31
- def command_for(command = nil)
31
+ # @param profile [AgentProfile, nil] when given, the default command takes its tools, budget
32
+ # @return [Array<String>] `command` as words; `QA_MINER_AGENT` else the default when nil
33
+ def command_for(command = nil, profile = nil)
32
34
  command ||= ENV.fetch("QA_MINER_AGENT", nil)
33
- return DEFAULT_COMMAND unless command
35
+ return default_command(profile) unless command
34
36
 
35
37
  command.is_a?(String) ? Shellwords.split(command) : command
36
38
  end
@@ -41,11 +43,13 @@ module Hecks
41
43
  # @param command [String, Array<String>, nil] the agent's command; see `command_for`
42
44
  # @param chdir [String] the directory the agent works in
43
45
  # @param log [String, nil] a file the agent's output is appended to
46
+ # @param profile [AgentProfile, nil] what the run may do; nil runs it as the caller could
44
47
  # @return [String] the agent's output
45
- # @raise [Failed] when the agent cannot start or exits non-zero
46
- def ask(prompt:, chdir:, command: nil, log: nil)
47
- words = command_for(command)
48
- output, status = Open3.capture2e(*words, stdin_data: prompt, chdir: chdir)
48
+ # @raise [Failed] when the agent cannot start, runs past its timeout or exits non-zero
49
+ def ask(prompt:, chdir:, command: nil, log: nil, profile: nil)
50
+ words = command_for(command, profile)
51
+ refuse_unconfinable!(words, profile)
52
+ output, status = run(words, prompt, chdir, profile)
49
53
  File.open(log, "a") { |file| file.puts(output) } if log
50
54
  return output if status.success?
51
55
 
@@ -53,6 +57,58 @@ module Hecks
53
57
  rescue SystemCallError => e
54
58
  raise Failed, "agent could not start (#{words.first}): #{e.message}"
55
59
  end
60
+
61
+ private
62
+
63
+ # Permission rules are a feature of `claude`: any other command would run unconfined.
64
+ def refuse_unconfinable!(words, profile)
65
+ return if profile.nil? || profile.sandboxed? || words == default_command(profile)
66
+
67
+ raise Failed, "permission confinement applies only to the default claude command"
68
+ end
69
+
70
+ def run(words, prompt, chdir, profile)
71
+ return run_confined(words, prompt, chdir, profile) if profile
72
+
73
+ Open3.capture2e(*words, stdin_data: prompt, chdir: chdir)
74
+ end
75
+
76
+ def default_command(profile)
77
+ return DEFAULT_COMMAND unless profile
78
+
79
+ %w[claude -p --permission-mode] + [profile.permission_mode] + profile.tool_flags
80
+ end
81
+
82
+ # Runs `words` under the profile's sandbox with only the environment it names, in its own
83
+ # process group so a timeout takes the agent's children with it.
84
+ def run_confined(words, prompt, chdir, profile)
85
+ raise Failed, "no sandbox here; refusing to run an agent unconfined" unless profile.available?
86
+
87
+ Open3.popen2e(profile.environment, *profile.confine(words),
88
+ chdir: chdir, unsetenv_others: true, pgroup: true) do |stdin, out, waiter|
89
+ feed(stdin, prompt)
90
+ reader = Thread.new { out.read }
91
+ finish(waiter, reader, profile.timeout)
92
+ end
93
+ end
94
+
95
+ def feed(stdin, prompt)
96
+ stdin.write(prompt)
97
+ rescue Errno::EPIPE
98
+ nil
99
+ ensure
100
+ stdin.close
101
+ end
102
+
103
+ def finish(waiter, reader, timeout)
104
+ unless waiter.join(timeout)
105
+ Process.kill("KILL", -waiter.pid)
106
+ waiter.join
107
+ reader.join
108
+ raise Failed, "agent timed out after #{timeout}s"
109
+ end
110
+ [reader.value, waiter.value]
111
+ end
56
112
  end
57
113
  end
58
114
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # What one `Agent` run may do: which tools it holds, where it may write, what it may reach on
8
+ # the network, which environment variables it sees and how long it may run.
9
+ #
10
+ # Confinement uses the macOS sandbox. Where there is none, a confined run refuses to start
11
+ # rather than run unconfined.
12
+ class AgentProfile
13
+ # What the network rule lets through: nothing, outbound HTTPS and name lookups, or anything.
14
+ NETWORKS = %i[none https any].freeze
15
+
16
+ # How a run is confined: under the operating system's sandbox, or by the permission rules
17
+ # of the `claude` command itself. Only the second leaves the user's own login usable, since
18
+ # the sandbox refuses the keychain and `~/.claude`.
19
+ CONFINEMENTS = %i[sandbox permissions].freeze
20
+
21
+ # The variables every confined agent sees besides the ones its profile names.
22
+ BASE_ENV = %w[PATH HOME USER LOGNAME SHELL LANG LC_ALL TERM TMPDIR].freeze
23
+
24
+ # The tools that write files; a permissions-confined run holds them only through path rules.
25
+ WRITING_TOOLS = %w[Write Edit MultiEdit NotebookEdit].freeze
26
+
27
+ # Home-relative places an agent may not read: credentials and the harness's own state.
28
+ CREDENTIAL_PATHS = %w[
29
+ .ssh .aws .gnupg .claude .config/gh .config/op .gem/credentials .netrc Library/Keychains
30
+ ].freeze
31
+
32
+ SANDBOX = "/usr/bin/sandbox-exec"
33
+
34
+ # @return [Array<String>] the tools the agent is given
35
+ attr_reader :tools
36
+ # @return [Array<String>] the directories the agent may write, besides the temp directory
37
+ attr_reader :writable
38
+ # @return [Symbol] one of `NETWORKS`
39
+ attr_reader :network
40
+ # @return [Array<String>] the environment variable names the agent may see besides `BASE_ENV`
41
+ attr_reader :env
42
+ # @return [Integer, nil] seconds the run may take; nil for no limit
43
+ attr_reader :timeout
44
+ # @return [Float, nil] the most the run may spend, in dollars, for an agent that takes one
45
+ attr_reader :budget
46
+ # @return [Symbol] one of `CONFINEMENTS`
47
+ attr_reader :confinement
48
+
49
+ # @param tools [Array<String>] the tools the agent is given
50
+ # @param writable [Array<String>] directories the agent may write
51
+ # @param network [Symbol] one of `NETWORKS`
52
+ # @param env [Array<String>] environment variable names to pass through
53
+ # @param timeout [Integer, nil] seconds the run may take
54
+ # @param budget [Float, nil] dollars the run may spend
55
+ # @param confinement [Symbol] one of `CONFINEMENTS`; `network` applies to the sandbox only
56
+ # @raise [ArgumentError] when `network` or `confinement` is not one it knows
57
+ def initialize(tools: [], writable: [], network: :none, env: [], timeout: nil, budget: nil,
58
+ confinement: :sandbox)
59
+ unless NETWORKS.include?(network)
60
+ raise ArgumentError, "network must be one of #{NETWORKS.inspect}, not #{network.inspect}"
61
+ end
62
+ unless CONFINEMENTS.include?(confinement)
63
+ raise ArgumentError, "confinement must be one of #{CONFINEMENTS.inspect}, not #{confinement.inspect}"
64
+ end
65
+
66
+ @tools = tools.dup.freeze
67
+ @writable = writable.dup.freeze
68
+ @network = network
69
+ @env = env.dup.freeze
70
+ @timeout = timeout
71
+ @budget = budget
72
+ @confinement = confinement
73
+ end
74
+
75
+ # @return [Boolean] whether this profile is enforced by the operating system's sandbox
76
+ def sandboxed?
77
+ confinement == :sandbox
78
+ end
79
+
80
+ # @return [Boolean] whether this machine can apply this profile's confinement
81
+ def available?
82
+ !sandboxed? || File.executable?(SANDBOX)
83
+ end
84
+
85
+ # @param words [Array<String>] the agent's command
86
+ # @return [Array<String>] `words`, run under the sandbox with this profile's policy; the
87
+ # words unchanged when the confinement is the command's own permission rules
88
+ def confine(words)
89
+ sandboxed? ? [SANDBOX, "-p", policy, *words] : words
90
+ end
91
+
92
+ # @return [String] the `claude` permission mode this profile runs under
93
+ def permission_mode
94
+ sandboxed? ? "acceptEdits" : "dontAsk"
95
+ end
96
+
97
+ # @param source [Hash{String => String}] the environment to take values from
98
+ # @return [Hash{String => String}] only the variables this profile lets the agent see
99
+ def environment(source = ENV)
100
+ source.to_h.slice(*BASE_ENV, *env)
101
+ end
102
+
103
+ # The `claude` flags this profile's tools and budget become.
104
+ #
105
+ # @return [Array<String>]
106
+ def tool_flags
107
+ flags = tools.empty? ? [] : ["--tools", tools.join(","), "--allowedTools", *allowed_tools]
108
+ flags += ["--strict-mcp-config"] unless sandboxed?
109
+ flags + (budget ? ["--max-budget-usd", budget.to_s] : [])
110
+ end
111
+
112
+ # @return [String] the sandbox policy: reads open but for credentials, writes where allowed
113
+ def policy
114
+ [
115
+ "(version 1)", "(allow default)",
116
+ "(deny file-write*)", write_rule, "(deny file-read*#{paths_clause(credential_paths)})",
117
+ *network_rules
118
+ ].join("\n")
119
+ end
120
+
121
+ private
122
+
123
+ # The tools allowed without asking. A permissions-confined run holds the writing tools only
124
+ # through an `Edit` rule per writable directory, which every file-writing tool answers to.
125
+ def allowed_tools
126
+ return [tools.join(",")] if sandboxed?
127
+
128
+ tools - WRITING_TOOLS + writable.map { |path| "Edit(/#{real(path)}/**)" }
129
+ end
130
+
131
+ def write_rule
132
+ allowed = [real(Dir.tmpdir), *writable.map { |path| real(path) }]
133
+ devices = '(literal "/dev/null") (literal "/dev/tty") (regex #"^/dev/fd/")'
134
+ "(allow file-write*#{paths_clause(allowed)} #{devices})"
135
+ end
136
+
137
+ def credential_paths
138
+ CREDENTIAL_PATHS.map { |path| File.join(Dir.home, path) }
139
+ end
140
+
141
+ def paths_clause(paths)
142
+ paths.map { |path| " (subpath #{quote(path)})" }.join
143
+ end
144
+
145
+ def network_rules
146
+ case network
147
+ when :any then []
148
+ when :https then ["(deny network*)", '(allow network-outbound (remote tcp "*:443") (remote udp "*:53"))',
149
+ '(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))']
150
+ else ["(deny network*)"]
151
+ end
152
+ end
153
+
154
+ def real(path)
155
+ File.realpath(path)
156
+ rescue SystemCallError
157
+ File.expand_path(path)
158
+ end
159
+
160
+ def quote(path)
161
+ %("#{path.gsub('\\', '\\\\\\\\').gsub('"', '\\"')}")
162
+ end
163
+ end
164
+ end
165
+ end
@@ -16,23 +16,28 @@ module Hecks
16
16
  # The command behind `hecks quality_control target.mine_combinations`: asks an agent to mine the
17
17
  # adversarial corpus for new domain combinations, then checks them. It is opt-in and never run
18
18
  # by `hecks quality_control sweep.tick` (an agent call costs money and answers differently each
19
- # run).
20
- # `--brief` prints the agent's prompt and stops; `--from <dir>` re-checks an earlier run;
21
- # `--against <domain>` narrows the census; `--agent "cmd"` reads the prompt on stdin.
19
+ # run). `--brief` prints the agent's prompt and stops; `--from <dir>` re-checks an earlier run;
20
+ # `--against <domain>` narrows the census; `--agent "cmd"` reads the prompt on stdin;
21
+ # `--confine` limits the default agent to writing the run's candidates directory.
22
22
  #
23
23
  # The agent defaults to `claude -p` (`QA_MINER_AGENT` overrides); candidates that boot are
24
24
  # checked by `hecks quality_control target.check_generated_domains --source`, whose report is
25
25
  # this command's report. Exit 0: all clean. 2: a finding (see
26
- # `.claude/skills/hecks_qa/SKILL.md`). 1: an operational
27
- # error.
26
+ # `.claude/skills/hecks_qa/SKILL.md`). 1: an operational error.
28
27
  class QaMineCombinations
29
28
  EXIT_OK = 0
30
29
  EXIT_ERROR = 1
31
30
  RESULT_MARKER = "QA_GENERATED_RESULT "
32
31
 
32
+ # What `--confine` lets the default agent do: read anything, write the candidates directory,
33
+ # run for twenty minutes and spend two dollars.
34
+ CONFINED_TOOLS = %w[Read Glob Grep Write Edit].freeze
35
+ CONFINED_TIMEOUT = 1200
36
+ CONFINED_BUDGET = 2.0
37
+
33
38
  USAGE = "usage: hecks quality_control mine_combinations [--candidates N] [--rust] [--seeds K] " \
34
39
  "[--steps M] " \
35
- "[--adversarial F] [--repair-rounds R] [--agent CMD] [--from <candidates-dir>] " \
40
+ "[--adversarial F] [--repair-rounds R] [--agent CMD] [--confine] [--from <candidates-dir>] " \
36
41
  "[--against <domain> …] [--brief]"
37
42
 
38
43
  Miner = Hecks::Fuzzing::CombinationMiner
@@ -65,7 +70,8 @@ module Hecks
65
70
  @out_dir = @options[:from] || File.join(@run_dir, "candidates")
66
71
  @agent_log = File.join(@run_dir, "agent.log")
67
72
  @agent = Hecks::Adapters::Agent.new
68
- @command = @agent.command_for(@options[:agent])
73
+ @profile = confinement_profile if @options[:confine]
74
+ @command = @agent.command_for(@options[:agent], @profile)
69
75
  corpus = @options[:against].empty? ? Miner.corpus_paths(@root) : @options[:against]
70
76
  @brief = Miner.brief(corpus, root: @root, bug_titles: Miner.recent_bug_titles(@root))
71
77
  prompt = Miner.prompt(@root, @brief, count: @options[:candidates], out_dir: @out_dir)
@@ -89,7 +95,7 @@ module Hecks
89
95
 
90
96
  def parse(argv)
91
97
  options = { candidates: 3, rust: false, seeds: 5, steps: 25, adversarial: 0.3, repair_rounds: 1,
92
- agent: nil, from: nil, brief: false, against: [] }
98
+ agent: nil, from: nil, brief: false, confine: false, against: [] }
93
99
  until argv.empty?
94
100
  arg = argv.shift
95
101
  if %w[-h --help].include?(arg)
@@ -108,6 +114,7 @@ module Hecks
108
114
  options[key] = reader.call(argv.shift)
109
115
  when "--rust" then options[:rust] = true
110
116
  when "--brief" then options[:brief] = true
117
+ when "--confine" then options[:confine] = true
111
118
  when "--against" then options[:against] << File.expand_path(argv.shift)
112
119
  else abort "#{USAGE}\nunexpected argument: #{arg.inspect}"
113
120
  end
@@ -124,12 +131,19 @@ module Hecks
124
131
 
125
132
  # Returns nil when the agent finished, else why it did not.
126
133
  def ask_agent(prompt)
127
- @agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log)
134
+ @agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log, profile: @profile)
128
135
  nil
129
136
  rescue Hecks::Adapters::Agent::Failed => e
130
137
  e.message
131
138
  end
132
139
 
140
+ # The profile `--confine` runs the agent under: it writes only the candidates directory.
141
+ def confinement_profile
142
+ FileUtils.mkdir_p(@out_dir)
143
+ Hecks::Adapters::AgentProfile.new(confinement: :permissions, tools: CONFINED_TOOLS, writable: [@out_dir],
144
+ timeout: CONFINED_TIMEOUT, budget: CONFINED_BUDGET)
145
+ end
146
+
133
147
  def boot_error(candidate, boot_root)
134
148
  dir = Hecks::Fuzzing::DomainGenerator.write(
135
149
  { "source" => File.read(candidate[:bluebook]), "aggregates" => [], "policies" => [] },
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.1.2".freeze
4
+ VERSION = "3.1.3".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.1.2
1
+ 3.1.3
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.2
4
+ version: 3.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -35,6 +35,14 @@ executables:
35
35
  extensions: []
36
36
  extra_rdoc_files: []
37
37
  files:
38
+ - examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb
39
+ - examples/pizzas/bluebook/pizzas.behaviors
40
+ - examples/pizzas/bluebook/pizzas.bluebook
41
+ - examples/pizzas/bluebook/pizzas.hecksagon
42
+ - examples/pizzas/bluebook/pizzas.world
43
+ - examples/pizzas/bluebook/translations/2-77625c.bluebook
44
+ - examples/pizzas/oidc.json
45
+ - examples/pizzas/pizzas_behaviors.hecksagon
38
46
  - exe/hecks
39
47
  - lib/hecks.rb
40
48
  - lib/hecks/adapters.rb
@@ -618,6 +626,7 @@ files:
618
626
  - lib/hecks/projector/target.rb
619
627
  - lib/hecks/quality_control/adapters/agent.adapter
620
628
  - lib/hecks/quality_control/adapters/agent.rb
629
+ - lib/hecks/quality_control/adapters/agent_profile.rb
621
630
  - lib/hecks/quality_control/adapters/angle_tools.adapter
622
631
  - lib/hecks/quality_control/adapters/angle_tools.rb
623
632
  - lib/hecks/quality_control/adapters/clearance_recorder.rb