fix-cli 0.6.1__tar.gz → 0.6.4__tar.gz

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.
Files changed (41) hide show
  1. {fix_cli-0.6.1/fix_cli.egg-info → fix_cli-0.6.4}/PKG-INFO +3 -2
  2. {fix_cli-0.6.1 → fix_cli-0.6.4}/agent.py +6 -10
  3. {fix_cli-0.6.1 → fix_cli-0.6.4}/contract.py +1 -3
  4. {fix_cli-0.6.1 → fix_cli-0.6.4}/crypto.py +70 -21
  5. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix.py +21 -30
  6. {fix_cli-0.6.1 → fix_cli-0.6.4/fix_cli.egg-info}/PKG-INFO +3 -2
  7. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix_cli.egg-info/SOURCES.txt +1 -0
  8. {fix_cli-0.6.1 → fix_cli-0.6.4}/protocol.py +5 -4
  9. {fix_cli-0.6.1 → fix_cli-0.6.4}/pyproject.toml +1 -1
  10. fix_cli-0.6.4/scrubber.py +790 -0
  11. {fix_cli-0.6.1 → fix_cli-0.6.4}/server/app.py +167 -64
  12. fix_cli-0.6.4/server/escrow.py +702 -0
  13. {fix_cli-0.6.1 → fix_cli-0.6.4}/server/judge.py +98 -35
  14. {fix_cli-0.6.1 → fix_cli-0.6.4}/server/nano.py +71 -309
  15. {fix_cli-0.6.1 → fix_cli-0.6.4}/server/store.py +30 -26
  16. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_app.py +324 -31
  17. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_crypto.py +175 -0
  18. fix_cli-0.6.4/tests/test_escrow.py +406 -0
  19. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_judge.py +3 -3
  20. fix_cli-0.6.4/tests/test_nano.py +423 -0
  21. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_nano_integration.py +1 -0
  22. fix_cli-0.6.4/tests/test_security.py +153 -0
  23. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_server.py +9 -11
  24. fix_cli-0.6.1/scrubber.py +0 -358
  25. fix_cli-0.6.1/server/escrow.py +0 -555
  26. fix_cli-0.6.1/tests/test_escrow.py +0 -429
  27. fix_cli-0.6.1/tests/test_nano.py +0 -572
  28. {fix_cli-0.6.1 → fix_cli-0.6.4}/LICENSE +0 -0
  29. {fix_cli-0.6.1 → fix_cli-0.6.4}/README.md +0 -0
  30. {fix_cli-0.6.1 → fix_cli-0.6.4}/client.py +0 -0
  31. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix_cli.egg-info/dependency_links.txt +0 -0
  32. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix_cli.egg-info/entry_points.txt +0 -0
  33. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix_cli.egg-info/requires.txt +0 -0
  34. {fix_cli-0.6.1 → fix_cli-0.6.4}/fix_cli.egg-info/top_level.txt +0 -0
  35. {fix_cli-0.6.1 → fix_cli-0.6.4}/server/__init__.py +0 -0
  36. {fix_cli-0.6.1 → fix_cli-0.6.4}/setup.cfg +0 -0
  37. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_agent.py +0 -0
  38. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_cli.py +0 -0
  39. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_client.py +0 -0
  40. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_contract.py +0 -0
  41. {fix_cli-0.6.1 → fix_cli-0.6.4}/tests/test_scrubber.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: fix-cli
3
- Version: 0.6.1
3
+ Version: 0.6.4
4
4
  Summary: AI-powered command fixer with contract-based dispute resolution
5
5
  Author-email: Karan Sharma <karans4@protonmail.com>
6
6
  License: AGPL-3.0-or-later
@@ -15,6 +15,7 @@ Requires-Dist: uvicorn>=0.20; extra == "server"
15
15
  Requires-Dist: starlette>=0.27; extra == "server"
16
16
  Requires-Dist: ed25519-blake2b>=1.4; extra == "server"
17
17
  Requires-Dist: requests>=2.28; extra == "server"
18
+ Dynamic: license-file
18
19
 
19
20
  # fix
20
21
 
@@ -53,7 +53,9 @@ def build_agent_prompt(contract: dict, investigation_results: list[dict],
53
53
  parts.append("You are a fix agent. Your job is to diagnose and fix the problem described in this contract.")
54
54
  parts.append("You may decline (\"accepted\": false) before or after investigation, as long as you haven't accepted yet.\n")
55
55
  parts.append("## Contract\n")
56
+ parts.append('<user-content type="contract">')
56
57
  parts.append(json.dumps(contract, indent=2))
58
+ parts.append('</user-content>')
57
59
  parts.append("")
58
60
 
59
61
  if agent_memory:
@@ -75,7 +77,9 @@ def build_agent_prompt(contract: dict, investigation_results: list[dict],
75
77
  parts.append("## Investigation Results So Far\n")
76
78
  for i, r in enumerate(investigation_results, 1):
77
79
  parts.append(f"### Round {i}: `{r['command']}`")
80
+ parts.append(f'<user-content type="investigation_result">')
78
81
  parts.append(f"```\n{r['output']}\n```")
82
+ parts.append('</user-content>')
79
83
  parts.append("")
80
84
 
81
85
  parts.append("## Instructions\n")
@@ -406,12 +410,6 @@ class FixAgent:
406
410
  return False, f"bounty {bounty} below minimum {self.min_bounty}"
407
411
  if bounty > self.max_bounty:
408
412
  return False, f"bounty {bounty} above maximum {self.max_bounty}"
409
- # Check min_bond requirement against our capabilities
410
- terms = contract.get("terms", {})
411
- min_bond = terms.get("min_bond", "0")
412
- if min_bond and Decimal(min_bond) > Decimal("0"):
413
- pass # Agent can check their own balance here if needed
414
-
415
413
  match, reason = capabilities_match(self.capabilities, contract)
416
414
  if not match:
417
415
  return False, reason
@@ -492,13 +490,11 @@ async def _main():
492
490
  with open(key_path, "rb") as f:
493
491
  privkey = f.read(32)
494
492
  else:
495
- from crypto import generate_ed25519_keypair, pubkey_to_fix_id
493
+ from crypto import generate_ed25519_keypair, pubkey_to_fix_id, save_ed25519_key
496
494
  privkey, pubkey = generate_ed25519_keypair()
497
495
  os.makedirs(os.path.dirname(key_path), exist_ok=True)
498
- with open(key_path, "wb") as f:
499
- f.write(privkey)
496
+ save_ed25519_key(key_path, privkey)
500
497
  print(f"Generated new agent identity: {pubkey_to_fix_id(pubkey)}")
501
- print(f"Key saved to {key_path}")
502
498
 
503
499
  from crypto import ed25519_privkey_to_pubkey, pubkey_to_fix_id
504
500
  pubkey = ed25519_privkey_to_pubkey(privkey)
@@ -169,7 +169,7 @@ def build_contract(command, stderr, env_info, **kwargs):
169
169
 
170
170
  # Market mode: add escrow + terms
171
171
  if market:
172
- bounty_str = str(bounty) if bounty else "0.05"
172
+ bounty_str = str(bounty) if bounty else "0.19"
173
173
  contract["escrow"] = {
174
174
  "bounty": bounty_str,
175
175
  "currency": "XNO",
@@ -178,8 +178,6 @@ def build_contract(command, stderr, env_info, **kwargs):
178
178
  }
179
179
  contract["terms"] = {
180
180
  "cancellation": {
181
- "agent_fee": "0.002",
182
- "principal_fee": "0.002",
183
181
  "grace_period": 30,
184
182
  },
185
183
  "abandonment": {
@@ -14,6 +14,7 @@ import hmac as hmac_mod
14
14
  import json
15
15
  import math
16
16
  import os
17
+ import sqlite3
17
18
  import time as _time
18
19
 
19
20
  from cryptography.hazmat.primitives.asymmetric.ed25519 import (
@@ -96,8 +97,17 @@ def load_ed25519_key(path: str) -> bytes:
96
97
 
97
98
 
98
99
  def save_ed25519_key(path: str, key: bytes) -> None:
99
- """Save a 32-byte raw Ed25519 private key to file (mode 0600)."""
100
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
100
+ """Save a 32-byte raw Ed25519 private key to file (mode 0600).
101
+ Uses O_EXCL for new files to prevent symlink attacks.
102
+ Falls back to O_TRUNC for overwrites (existing file must be a regular file)."""
103
+ if os.path.exists(path):
104
+ # Overwrite: verify it's a regular file, not a symlink
105
+ if os.path.islink(path):
106
+ raise ValueError(f"Refusing to overwrite symlink: {path}")
107
+ fd = os.open(path, os.O_WRONLY | os.O_TRUNC, 0o600)
108
+ else:
109
+ # New file: O_EXCL prevents race conditions
110
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
101
111
  try:
102
112
  os.write(fd, key)
103
113
  finally:
@@ -283,30 +293,69 @@ REQUEST_MAX_AGE = 300 # 5 minutes
283
293
 
284
294
 
285
295
  class ReplayGuard:
286
- """Track seen signatures to prevent replay attacks. TTL matches REQUEST_MAX_AGE."""
296
+ """Track seen signatures to prevent replay attacks.
287
297
 
288
- def __init__(self, ttl: int = REQUEST_MAX_AGE):
289
- self._seen: dict[str, float] = {} # sig_hex -> expiry_timestamp
298
+ Uses SQLite for persistence across restarts and multi-process safety.
299
+ Falls back to in-memory dict if no db_path provided.
300
+ """
301
+
302
+ def __init__(self, ttl: int = REQUEST_MAX_AGE + 30, db_path: str = ""):
290
303
  self._ttl = ttl
304
+ self._db = None
305
+ if db_path:
306
+ import sqlite3
307
+ self._db = sqlite3.connect(db_path, check_same_thread=False)
308
+ self._db.execute("PRAGMA journal_mode=WAL")
309
+ self._db.execute(
310
+ "CREATE TABLE IF NOT EXISTS replay_guard "
311
+ "(sig TEXT PRIMARY KEY, expires_at REAL)"
312
+ )
313
+ self._db.commit()
314
+ self._seen: dict[str, float] = {}
291
315
  self._check_count = 0
292
316
 
293
317
  def check_and_record(self, sig_hex: str) -> bool:
294
318
  """Return False if sig was already seen, True if new (and record it)."""
295
- self._check_count += 1
296
- if self._check_count % 100 == 0:
297
- self._prune()
298
-
299
- now = _time.time()
300
- if sig_hex in self._seen:
301
- if now < self._seen[sig_hex]:
302
- return False # still valid, replay detected
303
- # expired entry, treat as new
304
- self._seen[sig_hex] = now + self._ttl
305
- return True
306
-
307
- def _prune(self):
308
319
  now = _time.time()
309
- self._seen = {k: v for k, v in self._seen.items() if v > now}
320
+ expires = now + self._ttl
321
+
322
+ if self._db:
323
+ # Periodic prune
324
+ self._check_count += 1
325
+ if self._check_count % 100 == 0:
326
+ self._db.execute("DELETE FROM replay_guard WHERE expires_at <= ?", (now,))
327
+ self._db.commit()
328
+ # Atomic check-and-insert
329
+ try:
330
+ self._db.execute(
331
+ "INSERT INTO replay_guard (sig, expires_at) VALUES (?, ?)",
332
+ (sig_hex, expires),
333
+ )
334
+ self._db.commit()
335
+ return True # new signature
336
+ except sqlite3.IntegrityError:
337
+ # Unique constraint violation = already seen, or expired
338
+ row = self._db.execute(
339
+ "SELECT expires_at FROM replay_guard WHERE sig = ?", (sig_hex,)
340
+ ).fetchone()
341
+ if row and row[0] > now:
342
+ return False # still valid, replay
343
+ # Expired, update
344
+ self._db.execute(
345
+ "UPDATE replay_guard SET expires_at = ? WHERE sig = ?",
346
+ (expires, sig_hex),
347
+ )
348
+ self._db.commit()
349
+ return True
350
+ else:
351
+ # In-memory fallback
352
+ self._check_count += 1
353
+ if self._check_count % 100 == 0:
354
+ self._seen = {k: v for k, v in self._seen.items() if v > now}
355
+ if sig_hex in self._seen and now < self._seen[sig_hex]:
356
+ return False
357
+ self._seen[sig_hex] = expires
358
+ return True
310
359
 
311
360
 
312
361
  def sign_request_ed25519(
@@ -352,9 +401,9 @@ def verify_request_ed25519(
352
401
  now = _time.time()
353
402
  age = now - ts
354
403
  if age < -30: # allow 30s clock skew for future timestamps
355
- return False, f"request timestamp is in the future (skew={int(-age)}s)"
404
+ return False, "request timestamp is in the future"
356
405
  if age > REQUEST_MAX_AGE:
357
- return False, f"request expired (age={int(age)}s, max={REQUEST_MAX_AGE}s)"
406
+ return False, "request expired"
358
407
 
359
408
  # Decode pubkey
360
409
  try:
@@ -85,21 +85,20 @@ INVESTIGATE_WHITELIST = {
85
85
  # Directory listing
86
86
  "ls", "find", "tree", "du",
87
87
  # Search
88
- "grep", "rg", "ag", "awk", "sed",
88
+ "grep", "rg", "ag",
89
89
  # Versions/info
90
90
  "which", "whereis", "type", "command", "uname", "arch", "lsb_release", "hostnamectl",
91
91
  # Package queries
92
92
  "dpkg", "apt", "apt-cache", "apt-file", "apt-list", "rpm", "pacman",
93
93
  "pip", "pip3", "npm", "gem", "cargo", "rustc",
94
94
  # Runtime versions
95
- "python3", "python", "node", "gcc", "g++", "make", "cmake", "java", "go", "ruby",
95
+ "gcc", "g++", "make", "cmake",
96
96
  "clang", "clang++", "ld", "as", "nasm",
97
97
  # Environment
98
- "env", "printenv", "echo", "id", "whoami", "pwd", "hostname",
98
+ "echo", "id", "whoami", "pwd", "hostname",
99
99
  # System info
100
100
  "lsmod", "lscpu", "free", "df", "mount", "ip", "ss", "ps",
101
- # Logs (read-only)
102
- "journalctl", "dmesg",
101
+ # (logs removed — system info leakage)
103
102
  # Misc
104
103
  "readlink", "realpath", "basename", "dirname", "diff", "cmp",
105
104
  "strings", "nm", "ldd", "objdump", "pkg-config", "test", "timeout",
@@ -134,6 +133,13 @@ def validate_investigate_command(cmd, root=None):
134
133
  if not cmd:
135
134
  return False, "empty command"
136
135
 
136
+ # Block ALL shell metacharacters — eliminates pipe injection, redirects,
137
+ # command chaining, and code execution via subshells in one check
138
+ SHELL_METACHARS = set('|;&$`()')
139
+ for ch in cmd:
140
+ if ch in SHELL_METACHARS:
141
+ return False, f"blocked: contains shell metacharacter '{ch}'"
142
+
137
143
  # Block command substitution: $(...), `...`, <(...)
138
144
  if re.search(r'\$\(', cmd):
139
145
  return False, "blocked: contains $() command substitution"
@@ -169,7 +175,7 @@ def validate_investigate_command(cmd, root=None):
169
175
  if first_word not in INVESTIGATE_WHITELIST:
170
176
  return False, f"'{first_word}' not in investigation whitelist"
171
177
  # Commands that run other commands — check their argument too
172
- if first_word in ("timeout", "time", "nice", "ionice", "strace"):
178
+ if first_word in ("timeout", "time", "nice", "ionice"):
173
179
  parts = subcmd.split()
174
180
  # Skip flags and the timeout value to find the actual command
175
181
  i = 1
@@ -180,34 +186,18 @@ def validate_investigate_command(cmd, root=None):
180
186
  if actual not in INVESTIGATE_WHITELIST:
181
187
  return False, f"'{actual}' (via {first_word}) not in investigation whitelist"
182
188
 
183
- # Block dangerous argument patterns for commands that can execute code
184
- EXEC_CAPABLE = {"python3", "python", "node", "ruby", "java", "go"}
185
- EXEC_FLAGS = {"-c", "-e", "--eval", "-exec", "--exec"}
189
+ # Block dangerous argument patterns for remaining whitelisted commands
186
190
  for subcmd in subcmds:
187
191
  subcmd = subcmd.strip()
188
192
  if not subcmd:
189
193
  continue
190
194
  parts = subcmd.split()
191
195
  first = os.path.basename(parts[0])
192
- # Block interpreter execution flags
193
- if first in EXEC_CAPABLE:
194
- for p in parts[1:]:
195
- if p in EXEC_FLAGS:
196
- return False, f"blocked: '{first} {p}' can execute arbitrary code"
197
196
  # Block find -exec
198
197
  if first == "find":
199
198
  for p in parts[1:]:
200
199
  if p in ("-exec", "-execdir", "-ok", "-okdir", "-delete"):
201
200
  return False, f"blocked: 'find {p}' can modify files"
202
- # Block awk/sed system() calls
203
- if first in ("awk", "gawk", "mawk", "nawk"):
204
- cmd_lower = subcmd.lower()
205
- if "system(" in cmd_lower or "getline" in cmd_lower:
206
- return False, f"blocked: '{first}' with system()/getline can execute code"
207
- if first == "sed":
208
- for p in parts[1:]:
209
- if p in ("-i", "--in-place"):
210
- return False, f"blocked: 'sed {p}' can modify files"
211
201
 
212
202
  # Root jail: check that all path arguments resolve inside root
213
203
  if root:
@@ -913,9 +903,11 @@ def _first_run_setup():
913
903
  backend_line = 'backend = "auto"'
914
904
  print(f" {C_DIM}Unrecognized key prefix, saved as custom.{C_RESET}")
915
905
  print(f" {C_DIM}Set FIX_API_URL and FIX_MODEL env vars for custom endpoints.{C_RESET}")
916
- with open(keyfile, "w") as f:
917
- f.write(key)
918
- os.chmod(keyfile, 0o600)
906
+ fd = os.open(keyfile, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
907
+ try:
908
+ os.write(fd, key.encode())
909
+ finally:
910
+ os.close(fd)
919
911
  with open(config_path, "w") as f:
920
912
  f.write(f'# fix global config -- {provider} API\n')
921
913
  f.write(f'{backend_line}\n')
@@ -2654,23 +2646,22 @@ def apply_fix(fix_cmd, original_cmd, verify_spec, safe_mode, cfg, contract=None)
2654
2646
  msg += " System unchanged."
2655
2647
  status(f"{C_YELLOW}\u2718{C_RESET}", msg)
2656
2648
  if contract and contract.get("escrow"):
2657
- cancel_fee = contract.get("terms", {}).get("cancellation", {}).get("agent_fee", "0.002")
2658
2649
  bounty = contract["escrow"]["bounty"]
2659
2650
  both_evil = "evil_agent" in judge_flags and "evil_principal" in judge_flags
2660
2651
  agent_evil = "evil_agent" in judge_flags
2661
2652
  principal_evil = "evil_principal" in judge_flags
2662
2653
  if both_evil:
2663
2654
  status(f"{C_RED}${C_RESET}",
2664
- f"Escrow: {bounty} + fees donated to charity (both parties flagged)")
2655
+ f"Escrow: {bounty} + bonds donated to charity (both parties flagged)")
2665
2656
  elif agent_evil:
2666
2657
  status(f"{C_RED}${C_RESET}",
2667
- f"Escrow: {bounty} returned to principal (agent flagged, forfeits cancellation fee)")
2658
+ f"Escrow: {bounty} returned to principal (agent flagged, bond to charity)")
2668
2659
  elif principal_evil:
2669
2660
  status(f"{C_RED}${C_RESET}",
2670
2661
  f"Escrow: {bounty} donated to charity (principal flagged)")
2671
2662
  else:
2672
2663
  status(f"{C_DIM}${C_RESET}",
2673
- f"Escrow: {bounty} returned to principal (agent pays {cancel_fee} cancellation fee)")
2664
+ f"Escrow: {bounty} returned to principal (minus platform fee)")
2674
2665
 
2675
2666
  return 0 if success else 1
2676
2667
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: fix-cli
3
- Version: 0.6.1
3
+ Version: 0.6.4
4
4
  Summary: AI-powered command fixer with contract-based dispute resolution
5
5
  Author-email: Karan Sharma <karans4@protonmail.com>
6
6
  License: AGPL-3.0-or-later
@@ -15,6 +15,7 @@ Requires-Dist: uvicorn>=0.20; extra == "server"
15
15
  Requires-Dist: starlette>=0.27; extra == "server"
16
16
  Requires-Dist: ed25519-blake2b>=1.4; extra == "server"
17
17
  Requires-Dist: requests>=2.28; extra == "server"
18
+ Dynamic: license-file
18
19
 
19
20
  # fix
20
21
 
@@ -31,4 +31,5 @@ tests/test_judge.py
31
31
  tests/test_nano.py
32
32
  tests/test_nano_integration.py
33
33
  tests/test_scrubber.py
34
+ tests/test_security.py
34
35
  tests/test_server.py
@@ -11,11 +11,10 @@ from enum import Enum
11
11
 
12
12
  PROTOCOL_VERSION = 2
13
13
 
14
- DEFAULT_BOUNTY = "0.05"
14
+ DEFAULT_BOUNTY = "0.19"
15
15
  DEFAULT_CURRENCY = "XNO"
16
16
  DEFAULT_CHAIN = "nano"
17
- DEFAULT_CANCEL_FEE = "0.005"
18
- MINIMUM_BOUNTY = "0.05"
17
+ MINIMUM_BOUNTY = "0.19"
19
18
  GRACE_PERIOD_SECONDS = 30
20
19
  ABANDONMENT_TIMEOUT = 120
21
20
  MAX_INVESTIGATION_ROUNDS = 5
@@ -32,7 +31,9 @@ DEFAULT_REVIEW_WINDOW = 7200 # 2 hours
32
31
  # Judge defaults
33
32
  DEFAULT_JUDGE_FEE = "0.17" # XNO -- each side stakes this as dispute bond
34
33
  DEFAULT_RULING_TIMEOUT = 60 # seconds judge has to rule
35
- DEFAULT_MIN_BOND = "0" # XNO -- minimum bond to accept a contract (0 = judge fee only)
34
+ # Inclusive bond: bounty + judge_fee. Both sides pay the same.
35
+ MIN_BOUNTY_EXCESS = Decimal("0.02") # Minimum bounty above judge_fee
36
+ CANCEL_FEE_RATE = Decimal("0.20") # 20% of excess bond (bounty) on cancellation
36
37
 
37
38
  # Tiered court system: escalating models and fees
38
39
  COURT_TIERS = [
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fix-cli"
7
- version = "0.6.1"
7
+ version = "0.6.4"
8
8
  description = "AI-powered command fixer with contract-based dispute resolution"
9
9
  readme = "README.md"
10
10
  license = {text = "AGPL-3.0-or-later"}