antigravity-sdk 0.4.2 → 0.5.4
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/VERSION +1 -0
- data/lib/antigravity/agent.rb +102 -3
- data/lib/antigravity/colors.rb +43 -0
- data/lib/antigravity/connection/binary_fetcher.rb +6 -2
- data/lib/antigravity/connection/local_connection.rb +1 -2
- data/lib/antigravity/conversation.rb +84 -15
- data/lib/antigravity/harness.rb +1 -2
- data/lib/antigravity/lifecycle_logger.rb +161 -0
- data/lib/antigravity/policy/constants.rb +154 -0
- data/lib/antigravity/policy.rb +272 -0
- data/lib/antigravity/skill.rb +5 -1
- data/lib/antigravity/version.rb +7 -1
- data/lib/antigravity.rb +3 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25e15da1171b05a2bcc0be36f79e4f1f6e6e0e6135d520d3776282007071ee05
|
|
4
|
+
data.tar.gz: 3ddfcb833bb6d1339573b81e5910f33c37cd4113bfb28c753367386f0d67d88e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70112ba04d101d659b82372f4f49f03a301d9d1e8fb4c89de871ccf2abdee7bed7017b3fe8440eff5c935902f0c545238081d2439e7aff1e4ca5a63d76f87e83
|
|
7
|
+
data.tar.gz: feb64a14685fefe355772238556f05e27a08aad6f9ad44ae74db4fd0c16e1821eda88f78dbcc24cf990cb90d744139697b06eb883da4fe1f447d67a9d486de7c
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.4
|
data/lib/antigravity/agent.rb
CHANGED
|
@@ -5,16 +5,17 @@ module Antigravity
|
|
|
5
5
|
|
|
6
6
|
attr_accessor :model, :system_instruction, :api_key
|
|
7
7
|
attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
|
|
8
|
-
:workspace, :connection, :conversation
|
|
8
|
+
:workspace, :connection, :conversation, :policy, :policies, :born_at
|
|
9
9
|
|
|
10
10
|
def initialize(model: nil, system_instruction: nil, tools: [],
|
|
11
|
-
skills: [], workspace: nil, auto_logger: true, log_file: nil, &block)
|
|
11
|
+
skills: [], policies: [], policy: nil, workspace: nil, auto_logger: true, log_file: nil, &block)
|
|
12
12
|
@model = model || Antigravity.config.default_model
|
|
13
13
|
@api_key = Antigravity.config.api_key
|
|
14
14
|
@system_instruction = system_instruction
|
|
15
|
-
@workspace =
|
|
15
|
+
@workspace = resolve_workspace(workspace)
|
|
16
16
|
@tools = tools.dup
|
|
17
17
|
@skills = []
|
|
18
|
+
@policies = []
|
|
18
19
|
@sidecars = []
|
|
19
20
|
@hooks = Hooks.new
|
|
20
21
|
@client = Client.new
|
|
@@ -22,6 +23,7 @@ module Antigravity
|
|
|
22
23
|
@connection = nil
|
|
23
24
|
@conversation = nil
|
|
24
25
|
@connected = false
|
|
26
|
+
@born_at = Time.now
|
|
25
27
|
|
|
26
28
|
# Register pre-provided tools into the tool runner
|
|
27
29
|
@tool_runner = ToolRunner.new
|
|
@@ -30,6 +32,15 @@ module Antigravity
|
|
|
30
32
|
# Load skills provided at construction (local paths or GitHub URLs)
|
|
31
33
|
add_skills(skills) unless Array(skills).empty?
|
|
32
34
|
|
|
35
|
+
# Resolve policy: sugar (symbol → preset, Policy object → use directly)
|
|
36
|
+
if policy
|
|
37
|
+
resolved = policy.is_a?(Symbol) ? Policy.preset(policy) : policy
|
|
38
|
+
enforce(resolved)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Load policies
|
|
42
|
+
policies.each { |p| enforce(p) }
|
|
43
|
+
|
|
33
44
|
# Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
|
|
34
45
|
if auto_logger && logger_enabled?
|
|
35
46
|
attach_logger(log_file)
|
|
@@ -38,6 +49,13 @@ module Antigravity
|
|
|
38
49
|
yield(self) if block_given?
|
|
39
50
|
end
|
|
40
51
|
|
|
52
|
+
def enforce(policy)
|
|
53
|
+
@policies << policy
|
|
54
|
+
before_tool_call do |tool_name, args|
|
|
55
|
+
policy.evaluate(tool_name, args)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
# --- Class Methods ---
|
|
42
60
|
|
|
43
61
|
# Block form: opens connection, yields agent, auto-closes.
|
|
@@ -51,11 +69,21 @@ module Antigravity
|
|
|
51
69
|
end
|
|
52
70
|
end
|
|
53
71
|
|
|
72
|
+
def workspace=(path)
|
|
73
|
+
@workspace = resolve_workspace(path)
|
|
74
|
+
end
|
|
75
|
+
|
|
54
76
|
# --- Connection Lifecycle ---
|
|
55
77
|
|
|
56
78
|
def connect!
|
|
57
79
|
return self if @connected
|
|
58
80
|
|
|
81
|
+
# Auto-attach lifecycle logger if enabled via env
|
|
82
|
+
if lifecycle_logger_enabled? && !@lifecycle_attached
|
|
83
|
+
LifecycleLogger.attach!(self, verbose: ENV['ANTIGRAVITY_LIFECYCLE_VERBOSE'] == '1')
|
|
84
|
+
@lifecycle_attached = true
|
|
85
|
+
end
|
|
86
|
+
|
|
59
87
|
@connection = Connection::LocalConnection.new
|
|
60
88
|
@connection.connect!
|
|
61
89
|
|
|
@@ -66,8 +94,28 @@ module Antigravity
|
|
|
66
94
|
)
|
|
67
95
|
|
|
68
96
|
harness_config = build_harness_config
|
|
97
|
+
|
|
98
|
+
# Emit indexing hooks — workspace indexing happens during session init
|
|
99
|
+
index_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @workspace
|
|
100
|
+
hooks.emit(:indexing_start, { workspace: @workspace }) if @workspace
|
|
101
|
+
|
|
69
102
|
@conversation.initialize_session!(harness_config: harness_config)
|
|
70
103
|
@connected = true
|
|
104
|
+
|
|
105
|
+
if @workspace
|
|
106
|
+
index_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - index_t0
|
|
107
|
+
hooks.emit(:indexing_done, { workspace: @workspace, elapsed: index_elapsed.round(2) })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Emit session_start hook
|
|
111
|
+
hooks.emit(:session_start, {
|
|
112
|
+
model: @model,
|
|
113
|
+
conversation_id: conversation_id,
|
|
114
|
+
workspace: @workspace,
|
|
115
|
+
skills_count: @skills.length,
|
|
116
|
+
tools_count: @tools.length,
|
|
117
|
+
})
|
|
118
|
+
|
|
71
119
|
self
|
|
72
120
|
end
|
|
73
121
|
|
|
@@ -76,6 +124,14 @@ module Antigravity
|
|
|
76
124
|
end
|
|
77
125
|
|
|
78
126
|
def close!
|
|
127
|
+
# Emit session_end hook before teardown
|
|
128
|
+
if @connected
|
|
129
|
+
hooks.emit(:session_end, {
|
|
130
|
+
turn_count: turn_count,
|
|
131
|
+
conversation_id: conversation_id,
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
|
|
79
135
|
@connected = false
|
|
80
136
|
@connection&.disconnect!
|
|
81
137
|
@connection = nil
|
|
@@ -85,6 +141,7 @@ module Antigravity
|
|
|
85
141
|
# --- Chat ---
|
|
86
142
|
|
|
87
143
|
def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
|
|
144
|
+
connect! unless @connected
|
|
88
145
|
emit_sidecar_event(:prompt_started, prompt: message)
|
|
89
146
|
hooks.run_pre_prompt(message)
|
|
90
147
|
|
|
@@ -118,6 +175,21 @@ module Antigravity
|
|
|
118
175
|
@conversation.session_summary(model: @model)
|
|
119
176
|
end
|
|
120
177
|
|
|
178
|
+
# Seconds since agent was created
|
|
179
|
+
def uptime
|
|
180
|
+
Time.now - @born_at
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Human-readable uptime: "1m 23.4s", "7.3s"
|
|
184
|
+
def uptime_human
|
|
185
|
+
secs = uptime
|
|
186
|
+
if secs >= 60
|
|
187
|
+
"#{(secs / 60).to_i}m #{(secs % 60).round(1)}s"
|
|
188
|
+
else
|
|
189
|
+
"#{secs.round(1)}s"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
121
193
|
# --- Tool Registration ---
|
|
122
194
|
|
|
123
195
|
def register_tool(tool_or_name = nil, description: "", &block)
|
|
@@ -223,6 +295,16 @@ module Antigravity
|
|
|
223
295
|
true
|
|
224
296
|
end
|
|
225
297
|
|
|
298
|
+
def lifecycle_logger_enabled?
|
|
299
|
+
# Explicit opt-in (strip handles trailing spaces in .env files)
|
|
300
|
+
return true if ENV['ANTIGRAVITY_LIFECYCLE']&.strip == '1'
|
|
301
|
+
# Rails/Rack test or development
|
|
302
|
+
return true if %w[test development].include?(ENV['RAILS_ENV']&.strip&.downcase)
|
|
303
|
+
return true if %w[test development].include?(ENV['RACK_ENV']&.strip&.downcase)
|
|
304
|
+
# Explicit opt-out
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
|
|
226
308
|
def build_harness_config
|
|
227
309
|
api_key = ENV.fetch('GEMINI_API_KEY') {
|
|
228
310
|
raise ConfigError, 'GEMINI_API_KEY environment variable is required'
|
|
@@ -308,5 +390,22 @@ module Antigravity
|
|
|
308
390
|
@skills << skill
|
|
309
391
|
skill
|
|
310
392
|
end
|
|
393
|
+
|
|
394
|
+
def resolve_workspace(val)
|
|
395
|
+
return nil if val.nil? || val == false
|
|
396
|
+
|
|
397
|
+
raw_path = case val
|
|
398
|
+
when :here, :current, true, '.'
|
|
399
|
+
'.'
|
|
400
|
+
else
|
|
401
|
+
val.to_s
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
expanded = File.expand_path(raw_path)
|
|
405
|
+
expanded += '/' unless expanded.end_with?('/')
|
|
406
|
+
|
|
407
|
+
$stderr.puts "📁 Setting workspace to \e[34m#{expanded}\e[0m" rescue nil
|
|
408
|
+
expanded
|
|
409
|
+
end
|
|
311
410
|
end
|
|
312
411
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lightweight ANSI color helpers for terminal output.
|
|
4
|
+
# No external dependencies. Safe for piped/non-TTY output.
|
|
5
|
+
module Antigravity
|
|
6
|
+
module Colors
|
|
7
|
+
CODES = {
|
|
8
|
+
reset: "\e[0m",
|
|
9
|
+
bold: "\e[1m",
|
|
10
|
+
dim: "\e[2m",
|
|
11
|
+
italic: "\e[3m",
|
|
12
|
+
# Foreground
|
|
13
|
+
gray: "\e[90m",
|
|
14
|
+
red: "\e[31m",
|
|
15
|
+
green: "\e[32m",
|
|
16
|
+
yellow: "\e[33m",
|
|
17
|
+
blue: "\e[34m",
|
|
18
|
+
magenta: "\e[35m",
|
|
19
|
+
cyan: "\e[36m",
|
|
20
|
+
white: "\e[37m",
|
|
21
|
+
# Bright
|
|
22
|
+
bright_green: "\e[92m",
|
|
23
|
+
bright_yellow: "\e[93m",
|
|
24
|
+
bright_cyan: "\e[96m",
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.colorize(text, *styles)
|
|
28
|
+
return text.to_s unless $stdout.tty?
|
|
29
|
+
prefix = styles.map { |s| CODES[s] || "" }.join
|
|
30
|
+
"#{prefix}#{text}#{CODES[:reset]}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.gray(text) = colorize(text, :gray)
|
|
34
|
+
def self.dim(text) = colorize(text, :dim)
|
|
35
|
+
def self.green(text) = colorize(text, :green)
|
|
36
|
+
def self.yellow(text) = colorize(text, :yellow)
|
|
37
|
+
def self.red(text) = colorize(text, :red)
|
|
38
|
+
def self.cyan(text) = colorize(text, :cyan)
|
|
39
|
+
def self.blue(text) = colorize(text, :blue)
|
|
40
|
+
def self.magenta(text) = colorize(text, :magenta)
|
|
41
|
+
def self.bold(text) = colorize(text, :bold)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -87,7 +87,7 @@ module Antigravity
|
|
|
87
87
|
|
|
88
88
|
unless wheel
|
|
89
89
|
# Try all versions for the platform
|
|
90
|
-
versions = data['releases']&.keys&.sort_by { |v| Gem::Version.
|
|
90
|
+
versions = data['releases']&.keys&.sort_by { |v| Gem::Version.correct?(v) ? Gem::Version.new(v) : Gem::Version.new('0') }&.reverse
|
|
91
91
|
versions&.each do |ver|
|
|
92
92
|
files = data.dig('releases', ver) || []
|
|
93
93
|
wheel = files.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
|
|
@@ -134,7 +134,11 @@ module Antigravity
|
|
|
134
134
|
# Wheels are just ZIP files. Extract the binary.
|
|
135
135
|
# Look for: google/antigravity/bin/localharness (or language_server)
|
|
136
136
|
extract_dir = Dir.mktmpdir('agy-extract-')
|
|
137
|
-
system('unzip', '-q', '-o', wheel_path, '-d', extract_dir)
|
|
137
|
+
success = system('unzip', '-q', '-o', wheel_path, '-d', extract_dir)
|
|
138
|
+
unless success
|
|
139
|
+
FileUtils.rm_rf(extract_dir)
|
|
140
|
+
raise HarnessNotFoundError, "Failed to extract wheel using unzip command"
|
|
141
|
+
end
|
|
138
142
|
|
|
139
143
|
# Search for the binary inside (prefer localharness over language_server)
|
|
140
144
|
candidates = Dir.glob("#{extract_dir}/**/localharness") +
|
|
@@ -121,8 +121,7 @@ module Antigravity
|
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
def perform_handshake!
|
|
124
|
-
storage_dir =
|
|
125
|
-
FileUtils.mkdir_p(storage_dir)
|
|
124
|
+
storage_dir = Dir.mktmpdir('antigravity-ruby-')
|
|
126
125
|
|
|
127
126
|
# Send InputConfig via stdin
|
|
128
127
|
input_config = Protocol.encode_input_config(
|
|
@@ -59,6 +59,11 @@ module Antigravity
|
|
|
59
59
|
@turn_count += 1
|
|
60
60
|
@last_turn_usage = empty_usage
|
|
61
61
|
|
|
62
|
+
# GHI #18: Drain any stale messages from the WebSocket buffer before sending
|
|
63
|
+
# a new prompt. This prevents leftover FULLY_IDLE from previous turns or init
|
|
64
|
+
# from being consumed by collect_response.
|
|
65
|
+
drain_stale_messages
|
|
66
|
+
|
|
62
67
|
# Send user input (protobuf InputEvent with user_input string field)
|
|
63
68
|
input_event = {
|
|
64
69
|
userInput: prompt
|
|
@@ -87,6 +92,20 @@ module Antigravity
|
|
|
87
92
|
|
|
88
93
|
private
|
|
89
94
|
|
|
95
|
+
# GHI #18: Non-blocking drain of stale WebSocket messages (FULLY_IDLE leftovers).
|
|
96
|
+
# During gaps between turns (e.g. voice transcription taking 5-10s), the harness
|
|
97
|
+
# may send trajectory updates that would confuse the next collect_response call.
|
|
98
|
+
def drain_stale_messages
|
|
99
|
+
drained = 0
|
|
100
|
+
loop do
|
|
101
|
+
msg = @ws.receive_json(timeout: 0.05, idle_timeout: 0.05) rescue nil
|
|
102
|
+
break unless msg
|
|
103
|
+
drained += 1
|
|
104
|
+
@hooks&.emit(:ws_message, { _debug: 'drained_stale_message', message_keys: msg.keys, drained_count: drained })
|
|
105
|
+
end
|
|
106
|
+
@hooks&.emit(:ws_message, { _debug: 'drain_complete', count: drained }) if drained > 0
|
|
107
|
+
end
|
|
108
|
+
|
|
90
109
|
def collect_response(timeout: Antigravity.config.timeout_llm, &block)
|
|
91
110
|
text_parts = []
|
|
92
111
|
thinking_parts = []
|
|
@@ -94,11 +113,14 @@ module Antigravity
|
|
|
94
113
|
tool_calls_count = 0
|
|
95
114
|
finished = false
|
|
96
115
|
finished_at = nil
|
|
116
|
+
seen_any_step = false # GHI #18: track if we've seen real work from this turn
|
|
117
|
+
turn_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
118
|
|
|
98
119
|
@ws.each_message(timeout: timeout) do |msg|
|
|
99
120
|
@hooks&.emit(:ws_message, msg)
|
|
100
121
|
|
|
101
122
|
if (step = msg[:stepUpdate])
|
|
123
|
+
seen_any_step = true
|
|
102
124
|
step_record = parse_step(step)
|
|
103
125
|
steps << step_record
|
|
104
126
|
|
|
@@ -109,10 +131,9 @@ module Antigravity
|
|
|
109
131
|
|
|
110
132
|
if step[:textDelta] && !step[:textDelta].empty? && is_model_step && is_target_user && !is_error_step
|
|
111
133
|
text_parts << step[:textDelta]
|
|
112
|
-
chunk =
|
|
134
|
+
chunk = Chunk.new(
|
|
113
135
|
content: step[:textDelta],
|
|
114
|
-
role: :assistant
|
|
115
|
-
delta: true
|
|
136
|
+
role: :assistant
|
|
116
137
|
)
|
|
117
138
|
block&.call(chunk)
|
|
118
139
|
end
|
|
@@ -144,30 +165,48 @@ module Antigravity
|
|
|
144
165
|
# Top-level tool call (custom tools are sent as separate messages, not in stepUpdate)
|
|
145
166
|
if (tool_call = msg[:toolCall])
|
|
146
167
|
tool_calls_count += 1
|
|
168
|
+
seen_any_step = true
|
|
147
169
|
handle_tool_call(tool_call)
|
|
148
170
|
end
|
|
149
171
|
|
|
150
|
-
# Usage update
|
|
172
|
+
# Usage update — do NOT set seen_any_step here!
|
|
173
|
+
# usageUpdate can leak from the previous turn and trick the GHI #18
|
|
174
|
+
# stale-FULLY_IDLE guard into accepting a stale FULLY_IDLE as real.
|
|
151
175
|
if (usage = msg[:usageUpdate])
|
|
152
176
|
update_usage(usage)
|
|
153
177
|
end
|
|
154
178
|
|
|
155
179
|
# Trajectory state: STATE_FULLY_IDLE / STATE_CANCELLED = turn complete (authoritative signal from harness)
|
|
156
|
-
#
|
|
180
|
+
# GHI #18 + #24 FIX: Only honor FULLY_IDLE if we've seen at least one stepUpdate or toolCall
|
|
181
|
+
# (NOT usageUpdate — it leaks across turns) from this turn, OR if enough time has elapsed
|
|
182
|
+
# (2s) that this can't be a stale leftover. A stale FULLY_IDLE from a previous turn sitting
|
|
183
|
+
# in the WebSocket buffer was causing collect_response to return immediately with 0B text.
|
|
157
184
|
if (traj = msg[:trajectoryStateUpdate])
|
|
158
185
|
if traj[:state].to_s =~ /FULLY_IDLE|CANCELLED/
|
|
159
|
-
|
|
160
|
-
|
|
186
|
+
if seen_any_step
|
|
187
|
+
finished = true
|
|
188
|
+
finished_at = Time.now
|
|
189
|
+
else
|
|
190
|
+
# Stale FULLY_IDLE — skip it (likely leftover from previous turn or init)
|
|
191
|
+
@hooks&.emit(:ws_message, { _debug: 'skipped_stale_fully_idle', seen_any_step: seen_any_step })
|
|
192
|
+
end
|
|
161
193
|
end
|
|
162
194
|
end
|
|
163
195
|
|
|
164
196
|
# Stop conditions (in priority order):
|
|
165
|
-
# 1. Session end — always stop
|
|
166
|
-
# 2.
|
|
167
|
-
|
|
197
|
+
# 1. Session end — always hard-stop immediately
|
|
198
|
+
# 2. Finished (DONE or FULLY_IDLE) WITH text — short drain for trailing usage
|
|
199
|
+
# 3. Finished WITHOUT text but with steps — model was working (thinking),
|
|
200
|
+
# text is likely still in-flight. Drain longer (3s) to catch it. (GHI #24)
|
|
201
|
+
# 4. Not finished but text started — idle timeout for more text
|
|
202
|
+
if msg.key?(:sessionEndResponse)
|
|
168
203
|
:stop
|
|
204
|
+
elsif finished && !text_parts.empty?
|
|
205
|
+
[:idle_timeout, 0.5]
|
|
206
|
+
elsif finished && text_parts.empty?
|
|
207
|
+
# FULLY_IDLE arrived but no text yet — wait longer for trailing text
|
|
208
|
+
[:idle_timeout, 3.0]
|
|
169
209
|
elsif !text_parts.empty?
|
|
170
|
-
# If assistant has sent text response, allow 3s idle timeout for trailing metadata
|
|
171
210
|
[:idle_timeout, 3.0]
|
|
172
211
|
end
|
|
173
212
|
end
|
|
@@ -208,10 +247,40 @@ module Antigravity
|
|
|
208
247
|
# Symbolize keys for Ruby kwargs
|
|
209
248
|
kwargs = args.transform_keys(&:to_sym)
|
|
210
249
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
250
|
+
# Emit tool_call hook BEFORE execution
|
|
251
|
+
@hooks&.emit(:tool_call, { tool_name: tool_name, params: args, tool_id: tool_id })
|
|
252
|
+
|
|
253
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
254
|
+
policy_check = @hooks ? @hooks.run_pre_tool(tool_name, args) : { allowed: true }
|
|
255
|
+
|
|
256
|
+
if !policy_check[:allowed]
|
|
257
|
+
reason = policy_check[:reason]
|
|
258
|
+
# Same format as client.rb sidecar emission
|
|
259
|
+
@hooks&.emit(:tool_blocked, { tool: tool_name, reason: reason })
|
|
260
|
+
result = "❌ TOOL BLOCKED: #{reason}"
|
|
261
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
262
|
+
|
|
263
|
+
# We run the post_tool hook here as well to satisfy AgentLogger's pairing
|
|
264
|
+
result = @hooks ? @hooks.run_post_tool(tool_name, args, result) : result
|
|
265
|
+
@hooks&.emit(:tool_result, { tool_name: tool_name, result: result.to_s, duration: duration, tool_id: tool_id })
|
|
266
|
+
else
|
|
267
|
+
begin
|
|
268
|
+
raw_result = @tool_runner.execute(tool_name, **kwargs)
|
|
269
|
+
|
|
270
|
+
# Run post_tool filters/maskers
|
|
271
|
+
result = @hooks ? @hooks.run_post_tool(tool_name, args, raw_result) : raw_result
|
|
272
|
+
|
|
273
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
274
|
+
|
|
275
|
+
# Emit tool_result hook AFTER execution
|
|
276
|
+
@hooks&.emit(:tool_result, { tool_name: tool_name, result: result.to_s, duration: duration, tool_id: tool_id })
|
|
277
|
+
rescue ToolNotFoundError => e
|
|
278
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
279
|
+
result = { error: e.message }
|
|
280
|
+
|
|
281
|
+
# Emit tool_error hook on failure
|
|
282
|
+
@hooks&.emit(:tool_error, { tool_name: tool_name, error: e.message, duration: duration, tool_id: tool_id })
|
|
283
|
+
end
|
|
215
284
|
end
|
|
216
285
|
|
|
217
286
|
# Send tool response back (protobuf InputEvent.tool_response format)
|
data/lib/antigravity/harness.rb
CHANGED
|
@@ -22,8 +22,7 @@ module Antigravity
|
|
|
22
22
|
return true
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
@stdin, @stdout, @stderr, wait_thr = Open3.popen3(cmd)
|
|
25
|
+
@stdin, @stdout, @stderr, wait_thr = Open3.popen3(@bin_path, "--port=#{@port}")
|
|
27
26
|
@pid = wait_thr.pid
|
|
28
27
|
|
|
29
28
|
at_exit { stop! }
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Auto-attachable lifecycle logger that prints colorful, compact status lines
|
|
4
|
+
# on every hook event. Inspired by Cloud Code's status bar.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# agent = Antigravity::Agent.new
|
|
8
|
+
# Antigravity::LifecycleLogger.attach!(agent)
|
|
9
|
+
#
|
|
10
|
+
# Or auto-attach via env:
|
|
11
|
+
# ANTIGRAVITY_LIFECYCLE=1
|
|
12
|
+
# RAILS_ENV=test (auto-attaches in test/development)
|
|
13
|
+
#
|
|
14
|
+
module Antigravity
|
|
15
|
+
class LifecycleLogger
|
|
16
|
+
C = Antigravity::Colors
|
|
17
|
+
|
|
18
|
+
# Compact status string — the "Cloud Code status bar" equivalent.
|
|
19
|
+
# Example: "T3 | 1.2k tok | 4 tools | 2.3s"
|
|
20
|
+
def self.status_line(agent)
|
|
21
|
+
turns = agent.turn_count rescue 0
|
|
22
|
+
summary = agent.session_summary rescue {}
|
|
23
|
+
tokens = summary[:total_tokens] || 0
|
|
24
|
+
tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
|
|
25
|
+
tok_str = "🪙#{tok_str}"
|
|
26
|
+
model = summary[:model] || agent.model || '?'
|
|
27
|
+
conv_id = (summary[:conversation_id] || '?')[0..7]
|
|
28
|
+
|
|
29
|
+
C.dim("T#{turns} | #{tok_str} tok | #{model} | #{conv_id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.attach!(agent, verbose: false)
|
|
33
|
+
new(verbose: verbose).attach(agent)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(verbose: false)
|
|
37
|
+
@verbose = verbose
|
|
38
|
+
@session_start_time = nil
|
|
39
|
+
@turn_start_time = nil
|
|
40
|
+
@turn_count = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attach(agent)
|
|
44
|
+
attach_session_hooks(agent)
|
|
45
|
+
attach_turn_hooks(agent)
|
|
46
|
+
attach_tool_hooks(agent)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def attach_session_hooks(agent)
|
|
52
|
+
logger = self
|
|
53
|
+
|
|
54
|
+
agent.hooks.on(:session_start) do |info|
|
|
55
|
+
logger.instance_variable_set(:@session_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
56
|
+
model = info[:model] || agent.model || '?'
|
|
57
|
+
conv_id = (info[:conversation_id] || '?')[0..11]
|
|
58
|
+
puts C.gray("\n🪝🟢 #{C.dim("session_start")} | model=#{C.cyan(model)} | conv=#{C.cyan(conv_id)}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
agent.hooks.on(:indexing_start) do |info|
|
|
62
|
+
ws = info[:workspace] || '?'
|
|
63
|
+
puts C.gray("🪝 📂 #{C.dim("indexing")} | #{C.blue(ws)}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
agent.hooks.on(:indexing_done) do |info|
|
|
67
|
+
ws = info[:workspace] || '?'
|
|
68
|
+
elapsed = info[:elapsed] ? "#{info[:elapsed]}s" : '?'
|
|
69
|
+
puts C.gray("🪝 ✅ #{C.dim("indexed")} | #{C.blue(ws)} | #{C.green(elapsed)}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
agent.hooks.on(:session_end) do |info|
|
|
73
|
+
elapsed = if logger.instance_variable_get(:@session_start_time)
|
|
74
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@session_start_time)).round(1)
|
|
75
|
+
else
|
|
76
|
+
'?'
|
|
77
|
+
end
|
|
78
|
+
turns = info[:turn_count] || agent.turn_count rescue 0
|
|
79
|
+
summary = agent.session_summary rescue {}
|
|
80
|
+
tokens = summary[:total_tokens] || 0
|
|
81
|
+
tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
|
|
82
|
+
puts C.gray("🪝🔴 #{C.dim("session_end")} | #{C.bold("#{turns} turns")} | 🪙#{tok_str} | #{elapsed}s")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def attach_turn_hooks(agent)
|
|
87
|
+
logger = self
|
|
88
|
+
|
|
89
|
+
agent.hooks.before_prompt do |text|
|
|
90
|
+
logger.instance_variable_set(:@turn_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
91
|
+
count = logger.instance_variable_get(:@turn_count) + 1
|
|
92
|
+
logger.instance_variable_set(:@turn_count, count)
|
|
93
|
+
preview = text.to_s[0..60].gsub("\n", ' ')
|
|
94
|
+
preview += '...' if text.to_s.length > 60
|
|
95
|
+
status = self.class.status_line(agent) rescue C.dim("T#{count}")
|
|
96
|
+
puts C.gray("🪝 ➡️ #{C.dim("pre_turn")} T#{count} | #{C.yellow("\"#{preview}\"")} | #{status}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
agent.hooks.after_response do |response|
|
|
100
|
+
elapsed = if logger.instance_variable_get(:@turn_start_time)
|
|
101
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@turn_start_time)).round(2)
|
|
102
|
+
else
|
|
103
|
+
'?'
|
|
104
|
+
end
|
|
105
|
+
content = response.respond_to?(:content) ? response.content.to_s : response.to_s
|
|
106
|
+
chars = content.length
|
|
107
|
+
lines = content.count("\n") + 1
|
|
108
|
+
preview = content[0..60]&.gsub("\n", ' ') || '(empty)'
|
|
109
|
+
preview += '...' if chars > 60
|
|
110
|
+
thinking_len = response.respond_to?(:thinking) ? (response.thinking&.length || 0) : 0
|
|
111
|
+
tool_count = response.respond_to?(:tool_calls_count) ? (response.tool_calls_count || 0) : 0
|
|
112
|
+
status = self.class.status_line(agent) rescue ''
|
|
113
|
+
|
|
114
|
+
parts = ["#{C.green("#{chars}B")} #{C.dim("#{lines}L")}"]
|
|
115
|
+
parts << "#{C.magenta("#{thinking_len}B")} think" if thinking_len > 0
|
|
116
|
+
parts << "#{C.cyan("#{tool_count}")} tools" if tool_count > 0
|
|
117
|
+
parts << "#{C.blue("#{elapsed}s")}"
|
|
118
|
+
|
|
119
|
+
puts C.gray("\n🪝 ⬅️ #{C.dim("post_turn")} T#{logger.instance_variable_get(:@turn_count)} | #{parts.join(' | ')} | #{status}")
|
|
120
|
+
if logger.instance_variable_get(:@verbose)
|
|
121
|
+
puts C.dim(" \"#{preview}\"")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def attach_tool_hooks(agent)
|
|
127
|
+
logger = self
|
|
128
|
+
|
|
129
|
+
agent.hooks.on(:tool_call) do |info|
|
|
130
|
+
name = info[:tool_name] || info[:name] || '?'
|
|
131
|
+
params_preview = (info[:params] || {}).keys.join(', ')
|
|
132
|
+
puts C.gray(" 🪝 🔧 #{C.dim("tool_call")} | #{C.cyan(name)}(#{C.dim(params_preview)})")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
agent.hooks.on(:tool_result) do |info|
|
|
136
|
+
name = info[:tool_name] || info[:name] || '?'
|
|
137
|
+
result_str = info[:result].to_s
|
|
138
|
+
result_len = result_str.bytesize rescue 0
|
|
139
|
+
preview = result_str[0..120].gsub("\n", ' ')
|
|
140
|
+
preview += '...' if result_str.length > 120
|
|
141
|
+
duration = info[:duration] ? "#{info[:duration].round(2)}s" : nil
|
|
142
|
+
parts = [C.cyan(name), "#{result_len}B"]
|
|
143
|
+
parts << duration if duration
|
|
144
|
+
puts C.gray(" 🪝 ✅ #{C.dim("tool_done")} | #{parts.join(' | ')}")
|
|
145
|
+
puts C.dim(" → #{C.yellow(preview)}") if result_len > 0
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
agent.hooks.on(:tool_blocked) do |info|
|
|
149
|
+
name = info[:tool] || '?'
|
|
150
|
+
reason = info[:reason] || 'policy'
|
|
151
|
+
puts C.red(" 🪝 🚫 #{C.dim("tool_deny")} | #{C.red(name)} — #{reason}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
agent.hooks.on(:tool_error) do |info|
|
|
155
|
+
name = info[:tool_name] || info[:name] || '?'
|
|
156
|
+
error = info[:error] || info[:message] || '?'
|
|
157
|
+
puts C.red(" 🪝 💥 #{C.dim("tool_error")} | #{C.red(name)} — #{error.to_s[0..80]}")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ==========================================================================
|
|
4
|
+
# Policy Constants — curated lists of commands, tools, and file patterns.
|
|
5
|
+
#
|
|
6
|
+
# 🛠️ MAINTAINER NOTES:
|
|
7
|
+
# - One entry per line for easy review in PRs.
|
|
8
|
+
# - Add new entries alphabetically within each group.
|
|
9
|
+
# - Multi-word commands use explicit quotes (no backslash escapes!).
|
|
10
|
+
# - Run `bundle exec rspec spec/antigravity/policy_spec.rb` after edits.
|
|
11
|
+
# ==========================================================================
|
|
12
|
+
|
|
13
|
+
module Antigravity
|
|
14
|
+
class Policy
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------
|
|
17
|
+
# 💀 Catastrophic commands — hard-denied in ALL presets, no exceptions.
|
|
18
|
+
# ------------------------------------------------------------------
|
|
19
|
+
CATASTROPHIC_CMDS = [
|
|
20
|
+
'dd if=/dev/urandom',
|
|
21
|
+
'dd if=/dev/zero',
|
|
22
|
+
'> /dev/sd',
|
|
23
|
+
'halt',
|
|
24
|
+
'mkfs',
|
|
25
|
+
'reboot',
|
|
26
|
+
'rm -rf /*',
|
|
27
|
+
'rm -rf /',
|
|
28
|
+
'rm -rf ~',
|
|
29
|
+
'shutdown',
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
# ⚠️ Risky commands — confirmed in :default, hard-denied in :cautious.
|
|
34
|
+
# Single-word or short patterns that match substring in command_line.
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
RISKY_CMDS = [
|
|
37
|
+
'chmod -R 777',
|
|
38
|
+
'chown -R',
|
|
39
|
+
'kill -9',
|
|
40
|
+
'killall',
|
|
41
|
+
'pkill',
|
|
42
|
+
'rm',
|
|
43
|
+
'xargs',
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# 🔥 Destructive git — nuke local changes, rewrite history.
|
|
48
|
+
# Confirmed in :default/:turbo, hard-denied in :cautious/:test.
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
DESTRUCTIVE_GIT_CMDS = [
|
|
51
|
+
'git checkout .',
|
|
52
|
+
'git checkout -- .',
|
|
53
|
+
'git clean -fd',
|
|
54
|
+
'git clean -fdx',
|
|
55
|
+
'git push --force',
|
|
56
|
+
'git push -f',
|
|
57
|
+
'git reset --hard',
|
|
58
|
+
'git stash drop',
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# ✅ Safe read-only shell commands — allowed even in :cautious.
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
SAFE_CMDS = %i[
|
|
65
|
+
cd
|
|
66
|
+
date
|
|
67
|
+
echo
|
|
68
|
+
hostname
|
|
69
|
+
ls
|
|
70
|
+
md5
|
|
71
|
+
md5sum
|
|
72
|
+
pwd
|
|
73
|
+
uname
|
|
74
|
+
wc
|
|
75
|
+
which
|
|
76
|
+
whoami
|
|
77
|
+
].freeze
|
|
78
|
+
|
|
79
|
+
# ⚠️ File-reading shell commands — can bypass view_file deny rules!
|
|
80
|
+
# Allowed in :default/:turbo/:test, but NOT in :cautious.
|
|
81
|
+
# If you deny `view_file` for a path, `cat` can circumvent it.
|
|
82
|
+
READ_CMDS = %i[
|
|
83
|
+
cat
|
|
84
|
+
head
|
|
85
|
+
strings
|
|
86
|
+
tail
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
# Safe git subcommands (read-only, no mutations)
|
|
90
|
+
SAFE_GIT_CMDS = [
|
|
91
|
+
'git branch',
|
|
92
|
+
'git diff',
|
|
93
|
+
'git log',
|
|
94
|
+
'git remote',
|
|
95
|
+
'git status',
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# 🔐 Sensitive file globs — writes to these require confirmation.
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
SENSITIVE_FILES = [
|
|
102
|
+
'.env',
|
|
103
|
+
'.env.*',
|
|
104
|
+
'*.key',
|
|
105
|
+
'*.pem',
|
|
106
|
+
'*.secret',
|
|
107
|
+
'id_rsa*',
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# 📂 Sandbox directories — always writable, even in production.
|
|
112
|
+
# Throwaway / output dirs where agents can freely write.
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
SANDBOX_DIRS = [
|
|
115
|
+
'out/*',
|
|
116
|
+
'scratch/*',
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# 🔧 Tool classifications
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# Read-only harness tools (always safe)
|
|
124
|
+
READONLY_TOOLS = %i[
|
|
125
|
+
find
|
|
126
|
+
grep_search
|
|
127
|
+
list_dir
|
|
128
|
+
read_url_content
|
|
129
|
+
search_web
|
|
130
|
+
view_file
|
|
131
|
+
].freeze
|
|
132
|
+
|
|
133
|
+
# Write harness tools
|
|
134
|
+
WRITE_TOOLS = %i[
|
|
135
|
+
file_edit
|
|
136
|
+
write_to_file
|
|
137
|
+
].freeze
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# 🗺️ Environment → preset mapping (for Policy.auto)
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
PRESET_NAMES = %i[auto cautious default test turbo].freeze
|
|
143
|
+
|
|
144
|
+
ENV_MAP = {
|
|
145
|
+
'dev' => :turbo,
|
|
146
|
+
'development' => :turbo,
|
|
147
|
+
'prod' => :cautious,
|
|
148
|
+
'production' => :cautious,
|
|
149
|
+
'staging' => :default,
|
|
150
|
+
'test' => :test,
|
|
151
|
+
}.freeze
|
|
152
|
+
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy/constants'
|
|
4
|
+
|
|
5
|
+
module Antigravity
|
|
6
|
+
# ==========================================================================
|
|
7
|
+
# Antigravity::Policy — Declarative tool-access control for agents.
|
|
8
|
+
#
|
|
9
|
+
# ⚠️ ORDER DOES NOT MATTER!
|
|
10
|
+
#
|
|
11
|
+
# The DSL is declarative, like SQL — not imperative like a script.
|
|
12
|
+
# Rules are evaluated by PRECEDENCE, not by insertion order.
|
|
13
|
+
# You can write `allow` before `deny` or vice versa — same result.
|
|
14
|
+
#
|
|
15
|
+
# Precedence (highest wins):
|
|
16
|
+
# 1. Tool specificity: specific tool > wildcard (nil)
|
|
17
|
+
# 2. Condition specificity: has `when:` > no `when:`
|
|
18
|
+
# 3. Restrictiveness: deny > confirm > allow
|
|
19
|
+
#
|
|
20
|
+
# Example — these two policies behave identically:
|
|
21
|
+
#
|
|
22
|
+
# Policy.define do Policy.define do
|
|
23
|
+
# allow :run_command deny :run_command, when: cmd('rm')
|
|
24
|
+
# deny :run_command, allow :run_command
|
|
25
|
+
# when: cmd('rm') end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# In both cases, `rm` is denied (conditional deny beats unconditional
|
|
29
|
+
# allow), and everything else is allowed.
|
|
30
|
+
#
|
|
31
|
+
# See policy/constants.rb for curated command/file/tool lists.
|
|
32
|
+
# ==========================================================================
|
|
33
|
+
class Policy
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Rule — a single allow/deny/confirm entry in a policy.
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
class Rule
|
|
39
|
+
attr_reader :action, :tool_name, :condition, :handler
|
|
40
|
+
|
|
41
|
+
def initialize(action, tool_name = nil, condition: nil, handler: nil)
|
|
42
|
+
@action = action
|
|
43
|
+
@tool_name = tool_name
|
|
44
|
+
@condition = condition
|
|
45
|
+
@handler = handler
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def matches?(tool, args)
|
|
49
|
+
return false if @tool_name && @tool_name.to_sym != tool.to_sym
|
|
50
|
+
return false if @condition && !@condition.call(name: tool, args: args)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Precedence order (higher = wins):
|
|
55
|
+
# 1. Tool specificity: Specific tool > Wildcard (nil)
|
|
56
|
+
# 2. Condition specificity: Has predicate > No predicate
|
|
57
|
+
# 3. Action restrictiveness: Deny > Confirm > Allow
|
|
58
|
+
def precedence
|
|
59
|
+
specificity = @tool_name ? 1 : 0
|
|
60
|
+
condition_score = @condition ? 1 : 0
|
|
61
|
+
action_score = case @action
|
|
62
|
+
when :deny then 3
|
|
63
|
+
when :confirm then 2
|
|
64
|
+
when :allow then 1
|
|
65
|
+
else 0
|
|
66
|
+
end
|
|
67
|
+
[specificity, condition_score, action_score]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Constructor & factory methods
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def initialize(&block)
|
|
76
|
+
@rules = []
|
|
77
|
+
@confirm_handler = nil
|
|
78
|
+
instance_eval(&block) if block_given?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.define(&block)
|
|
82
|
+
new(&block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.allow_all
|
|
86
|
+
new { allow_all }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.deny_all
|
|
90
|
+
new { deny_all }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Built-in presets
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# Resolve a preset by name (symbol).
|
|
98
|
+
# @param name [Symbol] :cautious, :default, :turbo, :test, or :auto
|
|
99
|
+
# @return [Policy]
|
|
100
|
+
def self.preset(name)
|
|
101
|
+
case name.to_sym
|
|
102
|
+
when :cautious then cautious
|
|
103
|
+
when :default then default
|
|
104
|
+
when :turbo then turbo
|
|
105
|
+
when :test then test
|
|
106
|
+
when :auto then auto
|
|
107
|
+
else
|
|
108
|
+
raise ArgumentError, "Unknown preset :#{name}. Choose from: #{PRESET_NAMES.map { |n| ":#{n}" }.join(', ')}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 🔒 Cautious — read-only free, confirm everything else, hard-deny destructive.
|
|
113
|
+
# Best for: untrusted environments, production agents.
|
|
114
|
+
# NOTE: cat/head/tail/ls NOT in safe list — they can bypass view_file deny rules.
|
|
115
|
+
def self.cautious
|
|
116
|
+
define do
|
|
117
|
+
deny_all
|
|
118
|
+
READONLY_TOOLS.each { |t| allow t }
|
|
119
|
+
allow :run_command, when: cmd(*SAFE_CMDS, *SAFE_GIT_CMDS)
|
|
120
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
121
|
+
deny :run_command, when: cmd(*RISKY_CMDS)
|
|
122
|
+
deny :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
123
|
+
WRITE_TOOLS.each { |t| confirm t }
|
|
124
|
+
# 📂 Sandbox dirs: always writable, even in production
|
|
125
|
+
WRITE_TOOLS.each { |t| allow t, when: path(*SANDBOX_DIRS) }
|
|
126
|
+
confirm :run_command
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ⚖️ Default — balanced: allow reads + writes, confirm dangerous shell, protect sensitive files.
|
|
131
|
+
# Best for: day-to-day development, pair programming with an agent.
|
|
132
|
+
def self.default
|
|
133
|
+
define do
|
|
134
|
+
deny_all
|
|
135
|
+
READONLY_TOOLS.each { |t| allow t }
|
|
136
|
+
WRITE_TOOLS.each { |t| allow t }
|
|
137
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
138
|
+
allow :run_command
|
|
139
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
140
|
+
confirm :run_command, when: cmd(*RISKY_CMDS)
|
|
141
|
+
confirm :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 🚀 Turbo — wide open with seatbelts: allow everything, only hard-deny catastrophic.
|
|
146
|
+
# Best for: trusted dev environments, rapid prototyping.
|
|
147
|
+
def self.turbo
|
|
148
|
+
define do
|
|
149
|
+
allow_all
|
|
150
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
151
|
+
confirm :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
152
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 🧪 Test — permissive for test runners, but sandboxed.
|
|
157
|
+
# Best for: CI, test suites, RAILS_ENV=test.
|
|
158
|
+
def self.test
|
|
159
|
+
define do
|
|
160
|
+
allow_all
|
|
161
|
+
deny :run_command, when: cmd(*CATASTROPHIC_CMDS)
|
|
162
|
+
deny :run_command, when: cmd(*DESTRUCTIVE_GIT_CMDS)
|
|
163
|
+
confirm :run_command, when: cmd(*RISKY_CMDS)
|
|
164
|
+
WRITE_TOOLS.each { |t| confirm t, when: path(*SENSITIVE_FILES) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# 🔮 Auto — reads RAILS_ENV, RACK_ENV, or ANTIGRAVITY_ENV and picks a preset.
|
|
169
|
+
# Falls back to :default if unrecognized or unset.
|
|
170
|
+
def self.auto
|
|
171
|
+
env = ENV['ANTIGRAVITY_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV']
|
|
172
|
+
preset_name = ENV_MAP.fetch(env.to_s.downcase, :default)
|
|
173
|
+
send(preset_name)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# DSL methods
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def allow(tool_name = nil, **kwargs)
|
|
181
|
+
@rules << Rule.new(:allow, tool_name, condition: kwargs[:when])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def deny(tool_name = nil, **kwargs)
|
|
185
|
+
@rules << Rule.new(:deny, tool_name, condition: kwargs[:when])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def confirm(tool_name = nil, **kwargs, &block)
|
|
189
|
+
@rules << Rule.new(:confirm, tool_name, condition: kwargs[:when], handler: block)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def allow_all
|
|
193
|
+
allow(nil)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def deny_all
|
|
197
|
+
deny(nil)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def on_confirm(&block)
|
|
201
|
+
@confirm_handler = block
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Predicate helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def cmd(*patterns)
|
|
209
|
+
->(ctx) do
|
|
210
|
+
args = ctx[:args]
|
|
211
|
+
cmd_arg = args[:command_line] || args['command_line'] || args[:CommandLine] || args['CommandLine']
|
|
212
|
+
return false unless cmd_arg
|
|
213
|
+
|
|
214
|
+
cmd_arg = cmd_arg.to_s
|
|
215
|
+
patterns.any? { |p| cmd_arg.include?(p.to_s) }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def path(*globs)
|
|
220
|
+
->(ctx) do
|
|
221
|
+
args = ctx[:args]
|
|
222
|
+
path_arg = args[:path] || args['path'] ||
|
|
223
|
+
args[:file] || args['file'] ||
|
|
224
|
+
args[:target] || args['target'] ||
|
|
225
|
+
args[:file_path] || args['file_path'] ||
|
|
226
|
+
args[:target_file] || args['target_file']
|
|
227
|
+
return false unless path_arg
|
|
228
|
+
|
|
229
|
+
path_arg = path_arg.to_s
|
|
230
|
+
globs.any? { |g| File.fnmatch?(g.to_s, path_arg) }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def args_match(**matchers)
|
|
235
|
+
->(ctx) do
|
|
236
|
+
args = ctx[:args]
|
|
237
|
+
matchers.any? do |k, v|
|
|
238
|
+
val = args[k.to_sym] || args[k.to_s]
|
|
239
|
+
val && v.match?(val.to_s)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Evaluation engine
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def evaluate(tool_name, args = {})
|
|
249
|
+
matching_rules = @rules.select { |r| r.matches?(tool_name, args) }
|
|
250
|
+
best_rule = matching_rules.max_by(&:precedence)
|
|
251
|
+
|
|
252
|
+
if best_rule
|
|
253
|
+
if best_rule.action == :confirm
|
|
254
|
+
handler = best_rule.handler || @confirm_handler
|
|
255
|
+
if handler
|
|
256
|
+
ctx = { name: tool_name, args: args }
|
|
257
|
+
result = handler.call(ctx)
|
|
258
|
+
{ status: result ? :allow : :deny }
|
|
259
|
+
else
|
|
260
|
+
{ status: :deny }
|
|
261
|
+
end
|
|
262
|
+
elsif best_rule.action == :deny
|
|
263
|
+
{ status: :deny, reason: "Denied by policy" }
|
|
264
|
+
else
|
|
265
|
+
{ status: :allow }
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
{ status: :deny } # Default to deny if no rules match
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
data/lib/antigravity/skill.rb
CHANGED
|
@@ -71,7 +71,11 @@ module Antigravity
|
|
|
71
71
|
|
|
72
72
|
content = File.read(skill_file, encoding: 'UTF-8')
|
|
73
73
|
if content =~ /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m
|
|
74
|
-
front_matter =
|
|
74
|
+
front_matter = begin
|
|
75
|
+
YAML.safe_load(Regexp.last_match(1)) || {}
|
|
76
|
+
rescue Psych::SyntaxError => e
|
|
77
|
+
raise ArgumentError, "Invalid YAML frontmatter in #{skill_file}: #{e.message}"
|
|
78
|
+
end
|
|
75
79
|
@name = front_matter["name"] || File.basename(@path)
|
|
76
80
|
@description = front_matter["description"] || ""
|
|
77
81
|
@metadata = front_matter.fetch("metadata", {})
|
data/lib/antigravity/version.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Antigravity
|
|
4
|
-
VERSION =
|
|
4
|
+
VERSION = begin
|
|
5
|
+
version_file = [
|
|
6
|
+
File.expand_path("../../VERSION", __dir__),
|
|
7
|
+
File.expand_path("../VERSION", __dir__)
|
|
8
|
+
].find { |f| File.exist?(f) }
|
|
9
|
+
version_file ? File.read(version_file).strip : "0.5.0"
|
|
10
|
+
end
|
|
5
11
|
end
|
data/lib/antigravity.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "antigravity/config"
|
|
|
14
14
|
require_relative "antigravity/message"
|
|
15
15
|
require_relative "antigravity/protocol"
|
|
16
16
|
require_relative "antigravity/harness"
|
|
17
|
+
require_relative "antigravity/policy"
|
|
17
18
|
require_relative "antigravity/hooks"
|
|
18
19
|
require_relative "antigravity/guards"
|
|
19
20
|
require_relative "antigravity/sidecar"
|
|
@@ -26,6 +27,8 @@ require_relative "antigravity/connection/binary_fetcher"
|
|
|
26
27
|
require_relative "antigravity/connection/websocket_client"
|
|
27
28
|
require_relative "antigravity/connection/local_connection"
|
|
28
29
|
require_relative "antigravity/conversation"
|
|
30
|
+
require_relative "antigravity/colors"
|
|
31
|
+
require_relative "antigravity/lifecycle_logger"
|
|
29
32
|
require_relative "antigravity/agent"
|
|
30
33
|
|
|
31
34
|
module Antigravity
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: antigravity-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4
|
|
4
|
+
version: 0.5.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Riccardo Carlesso
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '1.5'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: dotenv
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.0'
|
|
54
68
|
description: An elegant, Ruby-like SDK for building autonomous AI agents with Google
|
|
55
69
|
Antigravity.
|
|
56
70
|
email:
|
|
@@ -59,10 +73,12 @@ executables: []
|
|
|
59
73
|
extensions: []
|
|
60
74
|
extra_rdoc_files: []
|
|
61
75
|
files:
|
|
76
|
+
- VERSION
|
|
62
77
|
- lib/antigravity.rb
|
|
63
78
|
- lib/antigravity/agent.rb
|
|
64
79
|
- lib/antigravity/base.rb
|
|
65
80
|
- lib/antigravity/client.rb
|
|
81
|
+
- lib/antigravity/colors.rb
|
|
66
82
|
- lib/antigravity/config.rb
|
|
67
83
|
- lib/antigravity/connection/binary_fetcher.rb
|
|
68
84
|
- lib/antigravity/connection/local_connection.rb
|
|
@@ -73,7 +89,10 @@ files:
|
|
|
73
89
|
- lib/antigravity/guards.rb
|
|
74
90
|
- lib/antigravity/harness.rb
|
|
75
91
|
- lib/antigravity/hooks.rb
|
|
92
|
+
- lib/antigravity/lifecycle_logger.rb
|
|
76
93
|
- lib/antigravity/message.rb
|
|
94
|
+
- lib/antigravity/policy.rb
|
|
95
|
+
- lib/antigravity/policy/constants.rb
|
|
77
96
|
- lib/antigravity/protocol.rb
|
|
78
97
|
- lib/antigravity/sidecar.rb
|
|
79
98
|
- lib/antigravity/skill.rb
|