hecks 3.1.1 → 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 +4 -4
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
- data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
- data/examples/pizzas/bluebook/pizzas.world +3 -0
- data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
- data/examples/pizzas/oidc.json +24 -0
- data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
- data/lib/hecks/cli/console.rb +5 -2
- data/lib/hecks/doors/cli_runner.rb +6 -0
- data/lib/hecks/projections/deploy/box/settings.rb +8 -1
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
- data/lib/hecks/projections/deploy/box.rb +32 -2
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
- data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
- data/lib/hecks/projector/cli_projector.rb +51 -22
- data/lib/hecks/quality_control/adapters/agent.rb +63 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
- data/lib/hecks/version.rb +1 -1
- data/rust/codegen/src/commands.rs +4 -32
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/registration_receipt/tests.rs +39 -0
- data/rust/host/src/web/registration_receipt.rs +122 -0
- data/rust/host/src/web/registrations.rs +14 -2
- data/rust/host/src/web.rs +1 -0
- metadata +13 -2
|
@@ -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
|
|
422
|
-
#
|
|
423
|
-
# list, each name in full.
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
# @
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
# `--
|
|
21
|
-
# `--
|
|
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
|
-
@
|
|
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
|
@@ -444,14 +444,7 @@ pub fn emit_command(exemplar: &Exemplar, command: &Json, aggregate: &Json, domai
|
|
|
444
444
|
.collect();
|
|
445
445
|
|
|
446
446
|
let transition = mutations::lifecycle_transition_for(command, aggregate);
|
|
447
|
-
let transition_arg =
|
|
448
|
-
Some(t) => format!(
|
|
449
|
-
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
450
|
-
naming::ruby_inspect_string(&t.field),
|
|
451
|
-
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
452
|
-
),
|
|
453
|
-
None => "None".to_string(),
|
|
454
|
-
};
|
|
447
|
+
let transition_arg = mutations::transition_check_arg(transition.as_ref());
|
|
455
448
|
|
|
456
449
|
let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
|
|
457
450
|
// A `delegate` mutation is rendered by `delegation_of`, which overwrites `mutation_lines`;
|
|
@@ -772,14 +765,7 @@ fn delegation_of(exemplar: &Exemplar, command: &Json, aggregate: &Json, value_ob
|
|
|
772
765
|
})
|
|
773
766
|
.collect();
|
|
774
767
|
let transition = mutations::lifecycle_transition_for(target, entity);
|
|
775
|
-
let transition_arg =
|
|
776
|
-
Some(t) => format!(
|
|
777
|
-
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
778
|
-
naming::ruby_inspect_string(&t.field),
|
|
779
|
-
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
780
|
-
),
|
|
781
|
-
None => "None".to_string(),
|
|
782
|
-
};
|
|
768
|
+
let transition_arg = mutations::transition_check_arg(transition.as_ref());
|
|
783
769
|
let mut mutation_lines: Vec<String> = target
|
|
784
770
|
.get("mutations")
|
|
785
771
|
.map(Json::each)
|
|
@@ -896,14 +882,7 @@ pub fn emit_entity_command(
|
|
|
896
882
|
.collect();
|
|
897
883
|
|
|
898
884
|
let transition = mutations::lifecycle_transition_for(command, entity);
|
|
899
|
-
let transition_arg =
|
|
900
|
-
Some(t) => format!(
|
|
901
|
-
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
902
|
-
naming::ruby_inspect_string(&t.field),
|
|
903
|
-
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
904
|
-
),
|
|
905
|
-
None => "None".to_string(),
|
|
906
|
-
};
|
|
885
|
+
let transition_arg = mutations::transition_check_arg(transition.as_ref());
|
|
907
886
|
|
|
908
887
|
let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
|
|
909
888
|
let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, entity, command, value_objects_by_name, false)).collect();
|
|
@@ -1063,14 +1042,7 @@ pub fn emit_nested_entity_command(
|
|
|
1063
1042
|
|
|
1064
1043
|
// The nested entity's lifecycle, as in `emit_entity_command`.
|
|
1065
1044
|
let transition = mutations::lifecycle_transition_for(command, nested);
|
|
1066
|
-
let transition_arg =
|
|
1067
|
-
Some(t) => format!(
|
|
1068
|
-
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
1069
|
-
naming::ruby_inspect_string(&t.field),
|
|
1070
|
-
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
1071
|
-
),
|
|
1072
|
-
None => "None".to_string(),
|
|
1073
|
-
};
|
|
1045
|
+
let transition_arg = mutations::transition_check_arg(transition.as_ref());
|
|
1074
1046
|
|
|
1075
1047
|
let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
|
|
1076
1048
|
let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, nested, command, value_objects_by_name, false)).collect();
|
|
@@ -392,6 +392,23 @@ pub struct Transition {
|
|
|
392
392
|
pub field: String,
|
|
393
393
|
pub to_state: String,
|
|
394
394
|
pub from_states: Vec<String>,
|
|
395
|
+
/// A row with no `from:` admits every state, so no check is emitted for the command.
|
|
396
|
+
pub unconstrained: bool,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// The `Option<TransitionCheck>` expression a dispatch function is generated with.
|
|
400
|
+
///
|
|
401
|
+
/// `None` covers both "no transition" and an unconstrained one; an empty `from_states` slice
|
|
402
|
+
/// would refuse every state instead of admitting every state.
|
|
403
|
+
pub fn transition_check_arg(transition: Option<&Transition>) -> String {
|
|
404
|
+
match transition {
|
|
405
|
+
Some(t) if !t.unconstrained => format!(
|
|
406
|
+
"Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
|
|
407
|
+
naming::ruby_inspect_string(&t.field),
|
|
408
|
+
t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
|
|
409
|
+
),
|
|
410
|
+
_ => "None".to_string(),
|
|
411
|
+
}
|
|
395
412
|
}
|
|
396
413
|
|
|
397
414
|
/// Collapses a command's transition rows into the shape `TransitionCheck` wants.
|
|
@@ -417,19 +434,25 @@ pub fn lifecycle_transition_for(command: &Json, aggregate: &Json) -> Option<Tran
|
|
|
417
434
|
}
|
|
418
435
|
}
|
|
419
436
|
let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
|
|
420
|
-
return Some(Transition { field, to_state: String::new(), from_states });
|
|
437
|
+
return Some(Transition { field, to_state: String::new(), from_states, unconstrained: false });
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
|
|
424
441
|
let to_state = rows[0].get("to_state").map(Json::to_s).unwrap_or_default();
|
|
425
442
|
let mut from_states: Vec<String> = Vec::new();
|
|
443
|
+
let mut unconstrained = false;
|
|
426
444
|
for row in &rows {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
445
|
+
match row.get_raw("from_state") {
|
|
446
|
+
None | Some(Json::Null) => unconstrained = true,
|
|
447
|
+
Some(from) => {
|
|
448
|
+
let from = from.to_s();
|
|
449
|
+
if !from_states.contains(&from) {
|
|
450
|
+
from_states.push(from);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
430
453
|
}
|
|
431
454
|
}
|
|
432
|
-
Some(Transition { field, to_state, from_states })
|
|
455
|
+
Some(Transition { field, to_state, from_states, unconstrained })
|
|
433
456
|
}
|
|
434
457
|
|
|
435
458
|
/// Right-hand side for a `set`, coercing the source into the target attribute's declared type.
|
|
@@ -782,3 +805,49 @@ fn emit_mutation_line_body(
|
|
|
782
805
|
other => panic!("unsupported mutation op {other:?} — command_skip_reason should have caught this"),
|
|
783
806
|
}
|
|
784
807
|
}
|
|
808
|
+
|
|
809
|
+
#[cfg(test)]
|
|
810
|
+
mod transition_tests {
|
|
811
|
+
use super::*;
|
|
812
|
+
|
|
813
|
+
fn aggregate(rows: &str) -> Json {
|
|
814
|
+
Json::parse(&format!(
|
|
815
|
+
r#"{{"name":"Account","lifecycle":{{"field":"status","default":"draft","transitions":[{rows}]}}}}"#
|
|
816
|
+
))
|
|
817
|
+
.unwrap()
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
fn command(name: &str) -> Json {
|
|
821
|
+
Json::parse(&format!(r#"{{"name":"{name}"}}"#)).unwrap()
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
#[test]
|
|
825
|
+
fn a_transition_without_from_admits_every_state() {
|
|
826
|
+
let account = aggregate(r#"{"command":"Open","to_state":"open","from_state":null}"#);
|
|
827
|
+
let transition = lifecycle_transition_for(&command("Open"), &account).unwrap();
|
|
828
|
+
assert!(transition.unconstrained);
|
|
829
|
+
assert_eq!(transition.to_state, "open");
|
|
830
|
+
assert_eq!(transition_check_arg(Some(&transition)), "None");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#[test]
|
|
834
|
+
fn a_transition_with_from_checks_that_state() {
|
|
835
|
+
let account = aggregate(r#"{"command":"Close","to_state":"closed","from_state":"open"}"#);
|
|
836
|
+
let transition = lifecycle_transition_for(&command("Close"), &account).unwrap();
|
|
837
|
+
assert!(!transition.unconstrained);
|
|
838
|
+
assert_eq!(
|
|
839
|
+
transition_check_arg(Some(&transition)),
|
|
840
|
+
r#"Some(crate::kernel::TransitionCheck { field: "status", from_states: &["open"] })"#
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
#[test]
|
|
845
|
+
fn one_unconstrained_row_among_constrained_ones_admits_every_state() {
|
|
846
|
+
let account = aggregate(
|
|
847
|
+
r#"{"command":"Reopen","to_state":"open","from_state":"closed"},
|
|
848
|
+
{"command":"Reopen","to_state":"open","from_state":null}"#,
|
|
849
|
+
);
|
|
850
|
+
let transition = lifecycle_transition_for(&command("Reopen"), &account).unwrap();
|
|
851
|
+
assert_eq!(transition_check_arg(Some(&transition)), "None");
|
|
852
|
+
}
|
|
853
|
+
}
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.1.
|
|
1
|
+
3.1.3
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[test]
|
|
4
|
+
fn cents_render_as_dollars_with_two_places() {
|
|
5
|
+
assert_eq!(dollars(10800), "$108.00");
|
|
6
|
+
assert_eq!(dollars(14805), "$148.05");
|
|
7
|
+
assert_eq!(dollars(5), "$0.05");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn the_built_in_receipt_names_the_event_the_first_name_and_the_amount() {
|
|
12
|
+
let body = built_in_body(Some("Ada"), "Yogadelics", Some(10800));
|
|
13
|
+
assert!(body.starts_with("Hi Ada,"));
|
|
14
|
+
assert!(body.contains("You're registered for Yogadelics."));
|
|
15
|
+
assert!(body.contains("$108.00"));
|
|
16
|
+
assert_eq!(receipt_subject("Yogadelics"), "You're registered for Yogadelics");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[test]
|
|
20
|
+
fn a_built_in_receipt_without_a_name_or_amount_still_reads_cleanly() {
|
|
21
|
+
let body = built_in_body(None, "Yogadelics", None);
|
|
22
|
+
assert!(body.starts_with("Hi,"));
|
|
23
|
+
assert!(!body.contains("payment of"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn the_sites_finished_email_is_read() {
|
|
28
|
+
let answer = serde_json::json!({"subject": "You're registered for Yogadelics", "body": "Hi Ada,\n\nWhere: Somewhere\n"});
|
|
29
|
+
assert_eq!(
|
|
30
|
+
site_email_from_json(&answer),
|
|
31
|
+
Some(SiteEmail { subject: "You're registered for Yogadelics".into(), body: "Hi Ada,\n\nWhere: Somewhere\n".into() })
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn an_incomplete_answer_from_the_site_falls_back_to_the_built_in_wording() {
|
|
37
|
+
assert_eq!(site_email_from_json(&serde_json::json!({"error": "no such session"})), None);
|
|
38
|
+
assert_eq!(site_email_from_json(&serde_json::json!({"subject": "Hi", "body": " "})), None);
|
|
39
|
+
}
|