antigravity-sdk 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2a5cad1edda5eb5efaa58d7d5104ba433f8469a923bb5f862b9f1bcb4219021
4
- data.tar.gz: 75c9924910ef8aab407444abfecfa0f7e989473d8be251691e4e07c9d04499aa
3
+ metadata.gz: effdabdb0588788c4b168749a2578e2e1f07aa877a54bfd80fe1969d42f497d2
4
+ data.tar.gz: 3debe5e4295f8c2e2bcd4c37ec7ea52ea11a77d034370320d4064b52aa08cc29
5
5
  SHA512:
6
- metadata.gz: ac637d9792051869f94b4a8ebc716fd604b2db18886e19b105190a9636c7e7bce418aeece52a360b139b1c534e560605b0dc578fab10b3e06ac913dc454663eb
7
- data.tar.gz: c8b6e4bee541853abd1f993d5423cc3426c830a24aba58f6790ec30e093279163b83e258a824811380ac5a603cba749dd7a099dee5ad4a2160fe25a7373c6e51
6
+ metadata.gz: 4123cbc798ddbfaa82ea0f854e89adb122491d9a99b52f501c15350401e660109be3ca778b6142720f0259256992dbbb73d4d2371d8bdefcda266bd4d98591cf
7
+ data.tar.gz: d80349e13c564c74bb81c5c8ba14887f39af36507d03b3c6a5883ed3a885359570210d10b9fc1f8cc3e8a0c193408de176f3316129a58e1d6303352c5527ed01
@@ -8,13 +8,14 @@ module Antigravity
8
8
  :workspace, :connection, :conversation
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
15
  @workspace = workspace ? File.expand_path(workspace) : nil
16
16
  @tools = tools.dup
17
17
  @skills = []
18
+ @policies = []
18
19
  @sidecars = []
19
20
  @hooks = Hooks.new
20
21
  @client = Client.new
@@ -30,6 +31,15 @@ module Antigravity
30
31
  # Load skills provided at construction (local paths or GitHub URLs)
31
32
  add_skills(skills) unless Array(skills).empty?
32
33
 
34
+ # Resolve policy: sugar (symbol → preset, Policy object → use directly)
35
+ if policy
36
+ resolved = policy.is_a?(Symbol) ? Policy.preset(policy) : policy
37
+ enforce(resolved)
38
+ end
39
+
40
+ # Load policies
41
+ policies.each { |p| enforce(p) }
42
+
33
43
  # Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
34
44
  if auto_logger && logger_enabled?
35
45
  attach_logger(log_file)
@@ -38,6 +48,13 @@ module Antigravity
38
48
  yield(self) if block_given?
39
49
  end
40
50
 
51
+ def enforce(policy)
52
+ @policies << policy
53
+ before_tool_call do |tool_name, args|
54
+ policy.evaluate(tool_name, args)
55
+ end
56
+ end
57
+
41
58
  # --- Class Methods ---
42
59
 
43
60
  # Block form: opens connection, yields agent, auto-closes.
@@ -56,6 +73,12 @@ module Antigravity
56
73
  def connect!
57
74
  return self if @connected
58
75
 
76
+ # Auto-attach lifecycle logger if enabled via env
77
+ if lifecycle_logger_enabled? && !@lifecycle_attached
78
+ LifecycleLogger.attach!(self, verbose: ENV['ANTIGRAVITY_LIFECYCLE_VERBOSE'] == '1')
79
+ @lifecycle_attached = true
80
+ end
81
+
59
82
  @connection = Connection::LocalConnection.new
60
83
  @connection.connect!
61
84
 
@@ -68,6 +91,16 @@ module Antigravity
68
91
  harness_config = build_harness_config
69
92
  @conversation.initialize_session!(harness_config: harness_config)
70
93
  @connected = true
94
+
95
+ # Emit session_start hook
96
+ hooks.emit(:session_start, {
97
+ model: @model,
98
+ conversation_id: conversation_id,
99
+ workspace: @workspace,
100
+ skills_count: @skills.length,
101
+ tools_count: @tools.length,
102
+ })
103
+
71
104
  self
72
105
  end
73
106
 
@@ -76,6 +109,14 @@ module Antigravity
76
109
  end
77
110
 
78
111
  def close!
112
+ # Emit session_end hook before teardown
113
+ if @connected
114
+ hooks.emit(:session_end, {
115
+ turn_count: turn_count,
116
+ conversation_id: conversation_id,
117
+ })
118
+ end
119
+
79
120
  @connected = false
80
121
  @connection&.disconnect!
81
122
  @connection = nil
@@ -85,6 +126,7 @@ module Antigravity
85
126
  # --- Chat ---
86
127
 
87
128
  def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
129
+ connect! unless @connected
88
130
  emit_sidecar_event(:prompt_started, prompt: message)
89
131
  hooks.run_pre_prompt(message)
90
132
 
@@ -223,6 +265,16 @@ module Antigravity
223
265
  true
224
266
  end
225
267
 
268
+ def lifecycle_logger_enabled?
269
+ # Explicit opt-in
270
+ return true if ENV['ANTIGRAVITY_LIFECYCLE'] == '1'
271
+ # Rails/Rack test or development
272
+ return true if %w[test development].include?(ENV['RAILS_ENV']&.downcase)
273
+ return true if %w[test development].include?(ENV['RACK_ENV']&.downcase)
274
+ # Explicit opt-out
275
+ false
276
+ end
277
+
226
278
  def build_harness_config
227
279
  api_key = ENV.fetch('GEMINI_API_KEY') {
228
280
  raise ConfigError, 'GEMINI_API_KEY environment variable is required'
@@ -308,5 +360,9 @@ module Antigravity
308
360
  @skills << skill
309
361
  skill
310
362
  end
363
+
364
+ def lifecycle_logger_enabled?
365
+ ENV['ANTIGRAVITY_LIFECYCLE'] == '1'
366
+ end
311
367
  end
312
368
  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
@@ -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
 
@@ -144,20 +166,31 @@ module Antigravity
144
166
  # Top-level tool call (custom tools are sent as separate messages, not in stepUpdate)
145
167
  if (tool_call = msg[:toolCall])
146
168
  tool_calls_count += 1
169
+ seen_any_step = true
147
170
  handle_tool_call(tool_call)
148
171
  end
149
172
 
150
173
  # Usage update
151
174
  if (usage = msg[:usageUpdate])
152
175
  update_usage(usage)
176
+ seen_any_step = true
153
177
  end
154
178
 
155
179
  # Trajectory state: STATE_FULLY_IDLE / STATE_CANCELLED = turn complete (authoritative signal from harness)
156
- # This is the MOST authoritative signal always stop, even with empty text.
180
+ # GHI #18 FIX: Only honor FULLY_IDLE if we've seen at least one stepUpdate/toolCall/usageUpdate
181
+ # from this turn, OR if enough time has elapsed (1s) that this can't be a stale leftover.
182
+ # A stale FULLY_IDLE from a previous turn sitting in the WebSocket buffer was causing
183
+ # collect_response to return immediately with zero steps/text.
157
184
  if (traj = msg[:trajectoryStateUpdate])
185
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - turn_started_at
158
186
  if traj[:state].to_s =~ /FULLY_IDLE|CANCELLED/
159
- finished = true
160
- finished_at = Time.now
187
+ if seen_any_step || elapsed > 1.0
188
+ finished = true
189
+ finished_at = Time.now
190
+ else
191
+ # Stale FULLY_IDLE — skip it (likely leftover from previous turn or init)
192
+ @hooks&.emit(:ws_message, { _debug: 'skipped_stale_fully_idle', elapsed: elapsed.round(3) })
193
+ end
161
194
  end
162
195
  end
163
196
 
@@ -0,0 +1,145 @@
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.dig(:tokens, :total) || 0
24
+ tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
25
+ model = summary[:model] || agent.model || '?'
26
+ conv_id = (summary[:conversation_id] || '?')[0..7]
27
+
28
+ C.dim("T#{turns} | #{tok_str} tok | #{model} | #{conv_id}")
29
+ end
30
+
31
+ def self.attach!(agent, verbose: false)
32
+ new(verbose: verbose).attach(agent)
33
+ end
34
+
35
+ def initialize(verbose: false)
36
+ @verbose = verbose
37
+ @session_start_time = nil
38
+ @turn_start_time = nil
39
+ @turn_count = 0
40
+ end
41
+
42
+ def attach(agent)
43
+ attach_session_hooks(agent)
44
+ attach_turn_hooks(agent)
45
+ attach_tool_hooks(agent)
46
+ end
47
+
48
+ private
49
+
50
+ def attach_session_hooks(agent)
51
+ logger = self
52
+
53
+ agent.hooks.on(:session_start) do |info|
54
+ logger.instance_variable_set(:@session_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
55
+ model = info[:model] || agent.model || '?'
56
+ conv_id = (info[:conversation_id] || '?')[0..11]
57
+ puts C.gray("🟢 #{C.dim("session_start")} | model=#{C.cyan(model)} | conv=#{C.cyan(conv_id)}")
58
+ end
59
+
60
+ agent.hooks.on(:session_end) do |info|
61
+ elapsed = if logger.instance_variable_get(:@session_start_time)
62
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@session_start_time)).round(1)
63
+ else
64
+ '?'
65
+ end
66
+ turns = info[:turn_count] || agent.turn_count rescue 0
67
+ summary = agent.session_summary rescue {}
68
+ tokens = summary.dig(:tokens, :total) || 0
69
+ tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
70
+ puts C.gray("🔴 #{C.dim("session_end")} | #{C.bold("#{turns} turns")} | #{tok_str} tok | #{elapsed}s")
71
+ end
72
+ end
73
+
74
+ def attach_turn_hooks(agent)
75
+ logger = self
76
+
77
+ agent.hooks.before_prompt do |text|
78
+ logger.instance_variable_set(:@turn_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
79
+ count = logger.instance_variable_get(:@turn_count) + 1
80
+ logger.instance_variable_set(:@turn_count, count)
81
+ preview = text.to_s[0..60].gsub("\n", ' ')
82
+ preview += '...' if text.to_s.length > 60
83
+ status = self.class.status_line(agent) rescue C.dim("T#{count}")
84
+ puts C.gray(" ➡️ #{C.dim("pre_turn")} T#{count} | #{C.yellow("\"#{preview}\"")} | #{status}")
85
+ end
86
+
87
+ agent.hooks.after_response do |response|
88
+ elapsed = if logger.instance_variable_get(:@turn_start_time)
89
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@turn_start_time)).round(2)
90
+ else
91
+ '?'
92
+ end
93
+ content = response.respond_to?(:content) ? response.content.to_s : response.to_s
94
+ chars = content.length
95
+ lines = content.count("\n") + 1
96
+ preview = content[0..60]&.gsub("\n", ' ') || '(empty)'
97
+ preview += '...' if chars > 60
98
+ thinking_len = response.respond_to?(:thinking) ? (response.thinking&.length || 0) : 0
99
+ tool_count = response.respond_to?(:tool_calls_count) ? (response.tool_calls_count || 0) : 0
100
+ status = self.class.status_line(agent) rescue ''
101
+
102
+ parts = ["#{C.green("#{chars}ch")} #{C.dim("#{lines}L")}"]
103
+ parts << "#{C.magenta("#{thinking_len}ch")} think" if thinking_len > 0
104
+ parts << "#{C.cyan("#{tool_count}")} tools" if tool_count > 0
105
+ parts << "#{C.blue("#{elapsed}s")}"
106
+
107
+ puts C.gray(" ⬅️ #{C.dim("post_turn")} T#{logger.instance_variable_get(:@turn_count)} | #{parts.join(' | ')} | #{status}")
108
+ if logger.instance_variable_get(:@verbose)
109
+ puts C.dim(" \"#{preview}\"")
110
+ end
111
+ end
112
+ end
113
+
114
+ def attach_tool_hooks(agent)
115
+ logger = self
116
+
117
+ agent.hooks.on(:tool_call) do |info|
118
+ name = info[:tool_name] || info[:name] || '?'
119
+ params_preview = (info[:params] || {}).keys.join(', ')
120
+ puts C.gray(" 🔧 #{C.dim("tool_call")} | #{C.cyan(name)}(#{C.dim(params_preview)})")
121
+ end
122
+
123
+ agent.hooks.on(:tool_result) do |info|
124
+ name = info[:tool_name] || info[:name] || '?'
125
+ result_len = info[:result].to_s.length rescue 0
126
+ duration = info[:duration] ? "#{info[:duration].round(2)}s" : nil
127
+ parts = [C.cyan(name), "#{result_len}ch"]
128
+ parts << duration if duration
129
+ puts C.gray(" ✅ #{C.dim("tool_done")} | #{parts.join(' | ')}")
130
+ end
131
+
132
+ agent.hooks.on(:tool_blocked) do |info|
133
+ name = info[:tool] || '?'
134
+ reason = info[:reason] || 'policy'
135
+ puts C.red(" 🚫 #{C.dim("tool_deny")} | #{C.red(name)} — #{reason}")
136
+ end
137
+
138
+ agent.hooks.on(:tool_error) do |info|
139
+ name = info[:tool_name] || info[:name] || '?'
140
+ error = info[:error] || info[:message] || '?'
141
+ puts C.red(" 💥 #{C.dim("tool_error")} | #{C.red(name)} — #{error.to_s[0..80]}")
142
+ end
143
+ end
144
+ end
145
+ 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.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.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Riccardo Carlesso
@@ -63,6 +63,7 @@ files:
63
63
  - lib/antigravity/agent.rb
64
64
  - lib/antigravity/base.rb
65
65
  - lib/antigravity/client.rb
66
+ - lib/antigravity/colors.rb
66
67
  - lib/antigravity/config.rb
67
68
  - lib/antigravity/connection/binary_fetcher.rb
68
69
  - lib/antigravity/connection/local_connection.rb
@@ -73,7 +74,10 @@ files:
73
74
  - lib/antigravity/guards.rb
74
75
  - lib/antigravity/harness.rb
75
76
  - lib/antigravity/hooks.rb
77
+ - lib/antigravity/lifecycle_logger.rb
76
78
  - lib/antigravity/message.rb
79
+ - lib/antigravity/policy.rb
80
+ - lib/antigravity/policy/constants.rb
77
81
  - lib/antigravity/protocol.rb
78
82
  - lib/antigravity/sidecar.rb
79
83
  - lib/antigravity/skill.rb