obey-cli 0.1.0__py3-none-any.whl

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 (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,166 @@
1
+ """Allow-listing HTTP CONNECT proxy for the sandbox (standard library only).
2
+
3
+ The agent container sits on an internal Docker network with no route out; this proxy, in the sidecar container,
4
+ is its only way to the internet. It tunnels CONNECT requests to allow-listed hosts on port 443, refuses
5
+ everything else, and logs every attempt as one JSON line.
6
+
7
+ python3 egress_proxy.py --listen 0.0.0.0:3128 --allow registry.npmjs.org --allow '*.pythonhosted.org' --log log
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import contextlib
15
+ import json
16
+ import sys
17
+ import time
18
+ from typing import Optional
19
+
20
+ MAX_HEADER = 16384
21
+ IDLE_TIMEOUT = 300.0 # seconds without a byte in either direction before a tunnel is dropped
22
+
23
+
24
+ def host_allowed(host: str, port: int, allow: "list[str]", ports: "tuple[int, ...]" = (443,)) -> bool:
25
+ host = host.lower().rstrip(".")
26
+ if port not in ports:
27
+ return False
28
+ for pattern in allow:
29
+ pattern = pattern.lower()
30
+ if pattern.startswith("*."):
31
+ if host.endswith(pattern[1:]) and host != pattern[2:]:
32
+ return True
33
+ elif host == pattern:
34
+ return True
35
+ return False
36
+
37
+
38
+ class Proxy:
39
+ def __init__(
40
+ self,
41
+ allow: "list[str]",
42
+ log_path: Optional[str],
43
+ ports: "tuple[int, ...]" = (443,),
44
+ idle_timeout: float = IDLE_TIMEOUT,
45
+ ):
46
+ self.allow = allow
47
+ self.ports = ports
48
+ self.log_path = log_path
49
+ self.idle_timeout = idle_timeout
50
+
51
+ def log(self, **fields: object) -> None:
52
+ line = json.dumps({"ts": round(time.time(), 3), **fields}, sort_keys=True)
53
+ if self.log_path:
54
+ with open(self.log_path, "a", encoding="utf-8") as fh:
55
+ fh.write(line + "\n")
56
+ else:
57
+ print(line, flush=True)
58
+
59
+ async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
60
+ peer = writer.get_extra_info("peername")
61
+ try:
62
+ head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=30)
63
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ConnectionError):
64
+ writer.close()
65
+ return
66
+ if len(head) > MAX_HEADER:
67
+ await self._refuse(writer, 431, "header too large")
68
+ return
69
+ request_line = head.split(b"\r\n", 1)[0].decode("latin-1")
70
+ parts = request_line.split()
71
+ if len(parts) != 3:
72
+ await self._refuse(writer, 400, "bad request")
73
+ return
74
+ method, target, _ = parts
75
+ if method.upper() != "CONNECT":
76
+ self.log(
77
+ event="refused", method=method, target=target[:200], reason="only CONNECT is allowed", client=str(peer)
78
+ )
79
+ await self._refuse(writer, 405, "only CONNECT is allowed")
80
+ return
81
+ host, _, port_text = target.rpartition(":")
82
+ host = host.strip("[]")
83
+ try:
84
+ port = int(port_text)
85
+ except ValueError:
86
+ await self._refuse(writer, 400, "bad port")
87
+ return
88
+ if not host_allowed(host, port, self.allow, self.ports):
89
+ self.log(event="refused", host=host, port=port, client=str(peer))
90
+ await self._refuse(writer, 403, "host not on the allow-list")
91
+ return
92
+ try:
93
+ up_reader, up_writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=20)
94
+ except (OSError, asyncio.TimeoutError) as exc:
95
+ self.log(event="upstream_error", host=host, port=port, error=type(exc).__name__)
96
+ await self._refuse(writer, 502, "upstream unreachable")
97
+ return
98
+ self.log(event="allowed", host=host, port=port, client=str(peer))
99
+ try:
100
+ writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
101
+ await writer.drain()
102
+ last = [asyncio.get_running_loop().time()]
103
+ await asyncio.gather(self._pipe(reader, up_writer, last), self._pipe(up_reader, writer, last))
104
+ except (OSError, asyncio.TimeoutError):
105
+ pass
106
+ finally: # whatever ended the tunnel, neither side may stay open
107
+ for end in (up_writer, writer):
108
+ with contextlib.suppress(Exception):
109
+ end.close()
110
+
111
+ async def _pipe(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, last: "list[float]") -> None:
112
+ """Copy bytes one way. `last` is when either direction last moved data; the tunnel ends after idle_timeout."""
113
+ loop = asyncio.get_running_loop()
114
+ try:
115
+ while True:
116
+ wait = self.idle_timeout - (loop.time() - last[0])
117
+ if wait <= 0:
118
+ break
119
+ try:
120
+ chunk = await asyncio.wait_for(reader.read(65536), timeout=wait)
121
+ except asyncio.TimeoutError:
122
+ continue
123
+ if not chunk:
124
+ break
125
+ last[0] = loop.time()
126
+ writer.write(chunk)
127
+ await asyncio.wait_for(writer.drain(), timeout=self.idle_timeout)
128
+ except (OSError, asyncio.TimeoutError, asyncio.CancelledError):
129
+ pass
130
+ finally:
131
+ with contextlib.suppress(Exception):
132
+ writer.close()
133
+
134
+ @staticmethod
135
+ async def _refuse(writer: asyncio.StreamWriter, status: int, reason: str) -> None:
136
+ body = reason.encode()
137
+ writer.write(
138
+ f"HTTP/1.1 {status} {reason}\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode() + body
139
+ )
140
+ with contextlib.suppress(Exception):
141
+ await writer.drain()
142
+ writer.close()
143
+
144
+
145
+ async def serve(listen: str, proxy: Proxy, ready: "Optional[asyncio.Event]" = None) -> None:
146
+ host, _, port = listen.rpartition(":")
147
+ server = await asyncio.start_server(proxy.handle, host or "127.0.0.1", int(port))
148
+ if ready is not None:
149
+ ready.set()
150
+ async with server:
151
+ await server.serve_forever()
152
+
153
+
154
+ def main(argv: "Optional[list[str]]" = None) -> int:
155
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
156
+ parser.add_argument("--listen", default="127.0.0.1:3128")
157
+ parser.add_argument("--allow", action="append", default=[], help="host or *.suffix (repeatable)")
158
+ parser.add_argument("--log", default=None, help="JSON-lines log file (default: stdout)")
159
+ parser.add_argument("--idle-timeout", type=float, default=IDLE_TIMEOUT, help="seconds of silence that end a tunnel")
160
+ args = parser.parse_args(argv)
161
+ asyncio.run(serve(args.listen, Proxy(args.allow, args.log, idle_timeout=args.idle_timeout)))
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())