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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|