agents_control 0.2.3 → 0.3.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.
@@ -27,7 +27,7 @@ module AgentsControl
27
27
 
28
28
  CHECKS = %i[
29
29
  ruby_check terminal_check automation_check agent_binary_check
30
- token_check chats_check bot_check daemon_check hooks_check
30
+ token_check chats_check passphrase_check bot_check daemon_check hooks_check
31
31
  anchors_check wake_check
32
32
  ].freeze
33
33
 
@@ -46,6 +46,24 @@ module AgentsControl
46
46
 
47
47
  private
48
48
 
49
+ # An unusable record is the case worth naming out loud: everywhere
50
+ # else it's treated as "no passphrase" so the command stays
51
+ # reachable, and without this nobody would ever learn that what
52
+ # they set isn't being used.
53
+ def passphrase_check
54
+ store = Passphrase.new(secrets: @secrets)
55
+
56
+ if store.unusable?
57
+ warn("passphrase", "stored, but not in a format this version reads",
58
+ "agents_control passphrase set — writes a fresh one")
59
+ elsif store.configured?
60
+ ok("passphrase", "set — destructive commands ask for it")
61
+ else
62
+ warn("passphrase", "not set — destructive commands ask for a tap",
63
+ "agents_control passphrase set")
64
+ end
65
+ end
66
+
49
67
  def ok(name, detail) = Check.new(name: name, status: :ok, detail: detail)
50
68
  def warn(name, detail, fix = nil) = Check.new(name: name, status: :warn, detail: detail, fix: fix)
51
69
  def fail(name, detail, fix = nil) = Check.new(name: name, status: :fail, detail: detail, fix: fix)
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module AgentsControl
6
+ # Files arriving from Telegram — a screenshot, a log, a CSV, a screen
7
+ # recording — landed on disk so an agent can read them.
8
+ #
9
+ # On disk, and not as a link, for one reason: Telegram serves file
10
+ # bytes from `/file/bot<TOKEN>/<path>`, so the download URL *is* the
11
+ # bot token. Handing that to an agent would put the token in its
12
+ # context, in the pane, and in the shell history — and this project
13
+ # treats a leaked bot token as equivalent to remote code execution.
14
+ # The URL is built inside Api#download and never leaves it. What the
15
+ # agent gets is a local path.
16
+ class Inbox
17
+ # Order matters: a photo comes as an array of sizes and the last is
18
+ # the largest, while a document carries the original filename. A
19
+ # message can hold exactly one of these.
20
+ KINDS = %w[document photo video audio voice video_note animation].freeze
21
+
22
+ # getFile refuses to serve anything larger, so a bigger file is
23
+ # rejected before the round trip rather than after it. The declared
24
+ # size is only the sender's word, though, so the same bound is
25
+ # enforced again on the bytes as they actually arrive.
26
+ MAX_BYTES = 20 * 1024 * 1024
27
+
28
+ # Kept out of the project directory on purpose: files arrive from a
29
+ # phone, and dropping them into whatever repository happened to be
30
+ # open would put them in front of `git status` and eventually into
31
+ # a commit.
32
+ def self.path
33
+ base = ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state")
34
+ File.join(base, "agents_control", "inbox")
35
+ end
36
+
37
+ Attachment = Struct.new(:file_id, :file_name, :file_size, :kind, keyword_init: true)
38
+
39
+ # The attachment in a message, or nil if it carries none.
40
+ def self.attachment(message)
41
+ KINDS.each do |kind|
42
+ value = message[kind]
43
+ next unless value
44
+
45
+ # photo is an array of increasing sizes; the last is the one
46
+ # worth having, since the smaller ones are Telegram's own
47
+ # thumbnails rather than what was sent.
48
+ descriptor = value.is_a?(Array) ? value.last : value
49
+ next unless descriptor.is_a?(Hash) && descriptor["file_id"]
50
+
51
+ return Attachment.new(file_id: descriptor["file_id"], file_name: descriptor["file_name"],
52
+ file_size: descriptor["file_size"], kind: kind)
53
+ end
54
+
55
+ nil
56
+ end
57
+
58
+ class TooLarge < StandardError; end
59
+
60
+ def initialize(api:, dir: self.class.path, keep_days: 14)
61
+ @api = api
62
+ @dir = dir
63
+ @keep_days = keep_days
64
+ end
65
+
66
+ # The local path the file landed at.
67
+ def fetch(attachment)
68
+ size = attachment.file_size.to_i
69
+ raise TooLarge, "#{human(size)} is over the #{human(MAX_BYTES)} Telegram allows a bot to download" if size > MAX_BYTES
70
+
71
+ remote = @api.file_path_for(attachment.file_id)
72
+ prepare
73
+ destination = File.join(@dir, name_for(attachment, remote))
74
+
75
+ download(remote, destination)
76
+ prune
77
+ destination
78
+ end
79
+
80
+ private
81
+
82
+ # Downloaded beside the destination and renamed into place: a
83
+ # connection that drops half way through otherwise leaves a
84
+ # truncated file with a name that promises a whole one, and the
85
+ # agent reads it as though it were complete.
86
+ def download(remote, destination)
87
+ partial = "#{destination}.part"
88
+ @api.download(remote, to: partial, max_bytes: MAX_BYTES)
89
+ File.chmod(0o600, partial)
90
+ File.rename(partial, destination)
91
+ ensure
92
+ FileUtils.rm_f(partial) if partial && File.exist?(partial)
93
+ end
94
+
95
+ def prepare
96
+ FileUtils.mkdir_p(@dir)
97
+ # Logs and screenshots are exactly the kind of thing that carries
98
+ # a token or a customer's name through in passing.
99
+ File.chmod(0o700, @dir)
100
+ end
101
+
102
+ # A timestamp prefix rather than a check for collisions: two
103
+ # screenshots sent a minute apart are both called Screenshot.png,
104
+ # and the second one silently replacing the first is worse than
105
+ # having both.
106
+ def name_for(attachment, remote)
107
+ stamp = Time.now.strftime("%Y%m%d-%H%M%S")
108
+ base = sanitize(attachment.file_name)
109
+ # The fallback extension comes from getFile's answer, which is
110
+ # Telegram's word and not ours — the API client escapes the same
111
+ # value per segment before it goes into a URL, and it gets the
112
+ # same treatment here rather than being spliced into a filename
113
+ # that will be typed into a shell.
114
+ base = "#{attachment.kind}#{extension(remote)}" if base.empty?
115
+
116
+ "#{stamp}-#{base}"
117
+ end
118
+
119
+ # Its own cleaning rather than sanitize's, which is written for a
120
+ # whole filename and strips the leading dot an extension needs.
121
+ # Everything but word characters and dots goes: this ends up in a
122
+ # path that gets typed into a shell, and it comes from getFile's
123
+ # answer — Telegram's word, not ours. The API client escapes the
124
+ # same value per segment before putting it in a URL.
125
+ def extension(remote)
126
+ File.extname(remote.to_s).gsub(/[^\w.]/, "")[0, 16]
127
+ end
128
+
129
+ # The name comes from whoever sent the file. It decides a path here,
130
+ # so it doesn't get to contain one: no separators, no traversal, no
131
+ # leading dot to hide the result, and short enough for any
132
+ # filesystem to accept.
133
+ def sanitize(name)
134
+ # Stripped before File.basename, which raises on a null byte
135
+ # rather than returning anything — and the name is JSON from
136
+ # Telegram, so it can carry one.
137
+ safe = name.to_s.delete("\u0000").tr("\n\r\t", " ")
138
+ cleaned = File.basename(safe).gsub(/[^\w.\- ]/, "_").squeeze("_").strip
139
+ cleaned = cleaned.sub(/\A\.+/, "")
140
+
141
+ cleaned.length > 96 ? "#{cleaned[0, 60]}#{File.extname(cleaned)[0, 16]}" : cleaned
142
+ end
143
+
144
+ # Nothing here is meant to be kept: an agent reads the file within
145
+ # minutes of it arriving. Without this the directory only ever
146
+ # grows, and it's full of screenshots and logs.
147
+ def prune
148
+ cutoff = Time.now - (@keep_days * 24 * 60 * 60)
149
+
150
+ Dir.glob(File.join(@dir, "*")).each do |path|
151
+ FileUtils.rm_f(path) if File.file?(path) && File.mtime(path) < cutoff
152
+ end
153
+ rescue SystemCallError
154
+ # A directory that can't be tidied is not a reason to refuse the
155
+ # file that was just asked for.
156
+ nil
157
+ end
158
+
159
+ def human(bytes) = "#{(bytes / 1024.0 / 1024.0).round(1)}MB"
160
+ end
161
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module AgentsControl
7
+ # The passphrase that unlocks a catastrophic command from Telegram.
8
+ #
9
+ # Stored the same place as the bot token — Keychain or libsecret,
10
+ # never the config file, never argv — and stored derived rather than
11
+ # plain: a token grants access, so it has to be readable back, but a
12
+ # passphrase only ever has to be *checked*, and there's no reason to
13
+ # keep something readable when a comparison would do.
14
+ #
15
+ # PBKDF2-HMAC-SHA256 through OpenSSL, which is stdlib. bcrypt or
16
+ # argon2 would be better at resisting an offline attack, and both are
17
+ # native extensions — the gem promises a pure-Ruby install with
18
+ # nothing to compile, and that promise is worth more here than the
19
+ # margin: anyone who can read this hash out of the Keychain can
20
+ # already read the bot token sitting next to it, which is the whole
21
+ # machine anyway.
22
+ class Passphrase
23
+ KEY = "run_passphrase"
24
+ ITERATIONS = 210_000
25
+ LENGTH = 32
26
+
27
+ # The wizard's two prompts are minutes apart at most, but what
28
+ # bridges them is written to the store on disk (0600, with a TTL) —
29
+ # so it gets the same cost as the record it will become. Cheaper
30
+ # here would mean the throwaway derivation is the easier of the two
31
+ # to attack offline, which is a strange place to save 200ms.
32
+ SEAL_ITERATIONS = ITERATIONS
33
+ PREFIX = "pbkdf2-sha256"
34
+
35
+ # A passphrase short enough to guess isn't protecting anything from
36
+ # someone holding an unlocked phone, which is the case this exists
37
+ # for in the first place.
38
+ MINIMUM = 8
39
+
40
+ class TooShort < StandardError; end
41
+
42
+ # The write went nowhere. Secrets#set reports which provider it
43
+ # picked, not whether that provider succeeded, and a Keychain that
44
+ # refused (locked, no authorization from a background daemon) would
45
+ # otherwise be indistinguishable from one that saved — leaving
46
+ # someone told their passphrase is set while destructive commands
47
+ # go on asking for a tap.
48
+ class NotSaved < StandardError; end
49
+
50
+ def initialize(secrets: Secrets.new)
51
+ @secrets = secrets
52
+ end
53
+
54
+ # "There's a record this version can actually check against", not
55
+ # "the entry isn't empty". The two diverge exactly when something
56
+ # unusable is stored — a plain string put in the environment
57
+ # variable, a truncated write, a format from a version this one was
58
+ # downgraded from — and every one of those would otherwise ask for
59
+ # a passphrase that can never match. Unusable is treated as absent,
60
+ # which falls back to the confirmation button rather than locking
61
+ # the command away. `doctor` reports the difference, so it isn't
62
+ # silent.
63
+ def configured? = !parse(stored).nil?
64
+
65
+ # Whether something is stored that isn't usable — for doctor, which
66
+ # is the one place that should say so out loud.
67
+ def unusable? = !stored.to_s.empty? && parse(stored).nil?
68
+
69
+ def set(plain)
70
+ value = plain.to_s
71
+ raise TooShort, "the passphrase needs at least #{MINIMUM} characters" if value.length < MINIMUM
72
+
73
+ salt = SecureRandom.hex(16)
74
+ @secrets.set(KEY, [PREFIX, ITERATIONS, salt, derive(value, salt, ITERATIONS)].join("$"))
75
+
76
+ raise NotSaved, "the secret store didn't keep it" unless matches?(value)
77
+
78
+ true
79
+ end
80
+
81
+ def clear = @secrets.delete(KEY)
82
+
83
+ # False whenever anything is off — no passphrase set, a record this
84
+ # version doesn't understand, an empty candidate. A check that
85
+ # can't be made is a check that didn't pass.
86
+ def matches?(candidate)
87
+ record = parse(stored)
88
+ return false unless record
89
+ return false if candidate.to_s.empty?
90
+
91
+ secure_compare(derive(candidate.to_s, record[:salt], record[:iterations]), record[:hash])
92
+ end
93
+
94
+ # Derives against a throwaway salt so two entries can be compared
95
+ # without either being kept. The wizard asks twice, and the first
96
+ # answer waits for the second in the store — as a derivation, never
97
+ # as the passphrase itself.
98
+ def self.sealed(value)
99
+ salt = SecureRandom.hex(16)
100
+ [salt, OpenSSL::KDF.pbkdf2_hmac(value.to_s, salt: salt, iterations: SEAL_ITERATIONS,
101
+ length: LENGTH, hash: "SHA256").unpack1("H*")]
102
+ end
103
+
104
+ def self.sealed_matches?(sealed, candidate)
105
+ salt, expected = sealed
106
+ return false if salt.to_s.empty? || candidate.to_s.empty?
107
+
108
+ OpenSSL.secure_compare(
109
+ OpenSSL::KDF.pbkdf2_hmac(candidate.to_s, salt: salt, iterations: SEAL_ITERATIONS,
110
+ length: LENGTH, hash: "SHA256").unpack1("H*"),
111
+ expected.to_s
112
+ )
113
+ end
114
+
115
+ private
116
+
117
+ # Read past the environment-variable provider on purpose. A
118
+ # passphrase is only ever checked, never read back, so there's no
119
+ # reason to accept one from the environment — and a plain one put
120
+ # in AGENTS_CONTROL_RUN_PASSPHRASE would take precedence over the
121
+ # Keychain, match nothing, and lock the command away for good:
122
+ # `passphrase clear` only writes to writable providers, and the
123
+ # environment isn't one.
124
+ def stored
125
+ @secrets.get(KEY, skip: Secrets::Providers::Env)
126
+ end
127
+
128
+ # A record is four fields with our prefix, a salt, a positive
129
+ # iteration count, and a hash. Anything else is not a passphrase
130
+ # this version can check.
131
+ def parse(record)
132
+ prefix, iterations, salt, hash = record.to_s.split("$", 4)
133
+ return unless prefix == PREFIX
134
+ return unless salt.to_s.length.positive? && hash.to_s.length.positive?
135
+ return unless iterations.to_s.match?(/\A[1-9][0-9]*\z/)
136
+
137
+ { iterations: iterations.to_i, salt: salt, hash: hash }
138
+ end
139
+
140
+ def derive(value, salt, iterations)
141
+ OpenSSL::KDF.pbkdf2_hmac(value, salt: salt.to_s, iterations: iterations,
142
+ length: LENGTH, hash: "SHA256").unpack1("H*")
143
+ end
144
+
145
+ # Fixed-time comparison. The attacker here is someone typing into a
146
+ # Telegram chat rather than measuring microseconds, so this is
147
+ # closer to hygiene than to a threat being answered — but a
148
+ # comparison that leaks its answer through timing is never the
149
+ # right one to write.
150
+ def secure_compare(one, two)
151
+ OpenSSL.secure_compare(one.to_s, two.to_s)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsControl
4
+ # Recognises a command from Telegram that must not go into a pane
5
+ # blind — the handful that destroy a disk or the machine outright.
6
+ #
7
+ # This is a seatbelt, not a security system. It answers one question:
8
+ # did the phone in someone's pocket just send something there is no
9
+ # coming back from? A wrong tab number in a list, a command pasted
10
+ # into the wrong chat, a thumb on the wrong line — those are the
11
+ # cases, and they're the reason the list stays small enough to hold
12
+ # in your head.
13
+ #
14
+ # The outcome is never a refusal. The person on the other end owns
15
+ # the machine; a check they can't get past doesn't prevent anything,
16
+ # it just makes them walk to the laptop and type it there.
17
+ class Policy
18
+ Verdict = Struct.new(:action, :reason, keyword_init: true) do
19
+ def allow? = action == :allow
20
+ def confirm? = action == :confirm
21
+ end
22
+
23
+ ALLOWED = Verdict.new(action: :allow).freeze
24
+
25
+ # Targets that make a recursive force delete unrecoverable rather
26
+ # than merely annoying. `rm -rf /tmp/build` is someone's ordinary
27
+ # Tuesday; `rm -rf ~` is the end of the machine.
28
+ FATAL_TARGETS = ["/", "/*", "~", "~/", "~/*", "$HOME", "$HOME/*", "${HOME}", "${HOME}/*",
29
+ "/usr", "/etc", "/var", "/System", "/Library", "/Applications",
30
+ "/Users", "/home", "/opt", "/bin", "/sbin"].freeze
31
+
32
+ # The bar for an entry: catastrophic, irreversible, and nobody
33
+ # types it from a phone on purpose. Not "sensitive", not "usually a
34
+ # mistake" — `cat .env` and `git push --force` are somebody's
35
+ # ordinary afternoon, and a list that stops those trains people to
36
+ # tap past it without reading, which costs more than it saves.
37
+ #
38
+ # Each carries its own plain-language reason. "Matches a pattern"
39
+ # tells someone glancing at a phone nothing they can act on; "this
40
+ # erases your home directory" does.
41
+ CATASTROPHIC = [
42
+ [/:\(\)\s*\{[^}]*\|[^}]*&[^}]*\}\s*;\s*:/,
43
+ "that's a fork bomb — it locks the machine up until it's power-cycled"],
44
+ [/\bmkfs(\.\w+)?\b/,
45
+ "mkfs formats a filesystem — everything on that device is gone"],
46
+ [/\bdd\b[^|;]*\bof=\/dev\//,
47
+ "dd writing to a raw device destroys whatever filesystem is on it"]
48
+ ].freeze
49
+
50
+ # An `rm` carrying both a recursive and a force flag — bundled
51
+ # (-rf, -fr, -Rf) or apart (-r -f, --recursive --force) — aimed at
52
+ # something there's no coming back from. Matched on the parsed
53
+ # operands rather than as a substring: "rm -rf /" and
54
+ # "rm -rf /tmp/build" differ by one character as text, and by
55
+ # everything else in what they do.
56
+ def self.reckless_delete(command)
57
+ words = command.to_s.split
58
+ # Matched on the command's basename, and case-insensitively:
59
+ # `/bin/rm` is the same program, and macOS's case-insensitive
60
+ # filesystem happily runs `RM` as well.
61
+ at = words.index { |word| File.basename(word).casecmp?("rm") }
62
+ return unless at
63
+
64
+ flags, operands = words.drop(at + 1).partition { |word| word.start_with?("-") }
65
+ joined = flags.join(" ")
66
+ return unless joined.match?(/--recursive|-[a-zA-Z]*[rR]/) && joined.match?(/--force|-[a-zA-Z]*f/)
67
+
68
+ target = operands.find { |operand| fatal_target?(operand) }
69
+ "this recursively deletes #{target} — there is no undo" if target
70
+ end
71
+
72
+ # Quotes come off before comparing: `rm -rf "$HOME"` is an entirely
73
+ # ordinary way to type the thing this exists to catch, and it isn't
74
+ # the same string as `rm -rf $HOME`.
75
+ def self.fatal_target?(operand)
76
+ bare = operand.to_s.gsub(/\A['"]|['"]\z/, "")
77
+
78
+ FATAL_TARGETS.include?(bare) || FATAL_TARGETS.include?(bare.chomp("/"))
79
+ end
80
+
81
+ def self.catastrophic(command)
82
+ reckless = reckless_delete(command)
83
+ return reckless if reckless
84
+
85
+ CATASTROPHIC.find { |pattern, _| command.to_s.match?(pattern) }&.last
86
+ end
87
+
88
+ def self.check(command)
89
+ fatal = catastrophic(command)
90
+
91
+ fatal ? Verdict.new(action: :confirm, reason: fatal) : ALLOWED
92
+ end
93
+ end
94
+ end
@@ -95,17 +95,28 @@ module AgentsControl
95
95
  # Looks for the last textual occurrence of "❯ 1. …" — the one
96
96
  # closest to the screen's current state, not a random numbered list
97
97
  # left over somewhere higher in the scrollback.
98
+ #
99
+ # Options don't have to be adjacent lines: a richer menu (a skill's
100
+ # own wizard, say) draws a couple of lines of description under
101
+ # each one, or a divider before a trailing meta-option ("Chat about
102
+ # this"). Anything that isn't itself the next numbered option is
103
+ # just skipped over rather than ending the scan — the option only
104
+ # actually ends once a numbered line shows up out of sequence
105
+ # (a stray list elsewhere on screen), which is what keeps this from
106
+ # picking up something unrelated further down.
98
107
  def parse_menu(text)
99
108
  lines = text.to_s.each_line.map(&:chomp)
100
109
  start = lines.rindex { |line| line.match?(CURSOR_OPTION) }
101
110
  return [] unless start
102
111
 
103
112
  options = [lines[start][CURSOR_OPTION, 1]]
104
- index = start + 1
105
113
 
106
- while index < lines.size && (m = lines[index].match(OPTION)) && m[1].to_i == options.size + 1
114
+ lines[(start + 1)..].to_a.each do |line|
115
+ m = line.match(OPTION)
116
+ next unless m
117
+ break unless m[1].to_i == options.size + 1
118
+
107
119
  options << m[2]
108
- index += 1
109
120
  end
110
121
 
111
122
  options.size >= 2 ? options : []
@@ -25,8 +25,12 @@ module AgentsControl
25
25
  @providers = providers || default_providers
26
26
  end
27
27
 
28
- def get(key)
28
+ # skip names a provider class to read past — for a secret that has
29
+ # no business coming from one of them (see Passphrase).
30
+ def get(key, skip: nil)
29
31
  readable.each do |provider|
32
+ next if skip && provider.is_a?(skip)
33
+
30
34
  value = provider.get(key.to_s)
31
35
  return value if value && !value.empty?
32
36
  end
@@ -61,8 +61,19 @@ module AgentsControl
61
61
  FIELD = "\x1F"
62
62
  RECORD = "\x1E"
63
63
 
64
+ # tmux 3.5 prints a non-printable byte in -F output as its octal
65
+ # escape — the four characters "\037" — where 3.7 emits the byte
66
+ # itself. Splitting on the byte then finds nothing, and the whole
67
+ # backend reports no panes at all: on Debian 13 and Ubuntu 24.10,
68
+ # whose tmux is 3.5a, the tool showed an empty tab list and gave
69
+ # no hint why. Both forms are accepted rather than betting on a
70
+ # version, and only these two sequences are converted — a window
71
+ # name containing the literal text is not something to go looking
72
+ # for.
73
+ ESCAPED = { "\\037" => FIELD, "\\036" => RECORD }.freeze
74
+
64
75
  def parse_records(output, fields)
65
- output.split(RECORD).filter_map do |record|
76
+ output.to_s.gsub(/\\03[67]/, ESCAPED).split(RECORD).filter_map do |record|
66
77
  next if record.strip.empty?
67
78
 
68
79
  values = record.split(FIELD, -1)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentsControl
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "shellwords"
4
5
 
5
6
  # The base error is declared before the other files: error classes
6
7
  # inherit from it right in a class body (the Telegram client, for
@@ -29,6 +30,9 @@ require_relative "agents_control/transcript"
29
30
  require_relative "agents_control/agents/base"
30
31
  require_relative "agents_control/agents/claude_code"
31
32
  require_relative "agents_control/hooks/server"
33
+ require_relative "agents_control/inbox"
34
+ require_relative "agents_control/passphrase"
35
+ require_relative "agents_control/policy"
32
36
  require_relative "agents_control/dispatcher"
33
37
  require_relative "agents_control/channels/base"
34
38
  require_relative "agents_control/channels/telegram/markdown"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agents_control
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sapar Kurmanov
@@ -58,9 +58,12 @@ files:
58
58
  - lib/agents_control/event.rb
59
59
  - lib/agents_control/executor.rb
60
60
  - lib/agents_control/hooks/server.rb
61
+ - lib/agents_control/inbox.rb
61
62
  - lib/agents_control/keyboard.rb
62
63
  - lib/agents_control/menu.rb
64
+ - lib/agents_control/passphrase.rb
63
65
  - lib/agents_control/pending.rb
66
+ - lib/agents_control/policy.rb
64
67
  - lib/agents_control/process_probe.rb
65
68
  - lib/agents_control/prompt.rb
66
69
  - lib/agents_control/rate_limit_watcher.rb