outfence 0.1.0a2__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.
- outfence/__init__.py +3 -0
- outfence/__main__.py +6 -0
- outfence/cli.py +253 -0
- outfence/console.py +309 -0
- outfence/core.py +7 -0
- outfence/demo.py +30 -0
- outfence/mascot.py +14 -0
- outfence/modeltap.py +97 -0
- outfence/policy.py +67 -0
- outfence/provenance.py +252 -0
- outfence/proxy.py +342 -0
- outfence/report.py +167 -0
- outfence-0.1.0a2.dist-info/METADATA +190 -0
- outfence-0.1.0a2.dist-info/RECORD +18 -0
- outfence-0.1.0a2.dist-info/WHEEL +5 -0
- outfence-0.1.0a2.dist-info/entry_points.txt +2 -0
- outfence-0.1.0a2.dist-info/licenses/LICENSE +202 -0
- outfence-0.1.0a2.dist-info/top_level.txt +1 -0
outfence/__init__.py
ADDED
outfence/__main__.py
ADDED
outfence/cli.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""CLI entry points and POSIX workload lifecycle."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import nullcontext
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from . import __version__, console
|
|
16
|
+
from .demo import Fixture, demo_requests
|
|
17
|
+
from .modeltap import ModelTap
|
|
18
|
+
from .policy import DEMO_POLICY, load_policy
|
|
19
|
+
from .provenance import Session
|
|
20
|
+
from .proxy import LoopbackHTTPServer, Proxy, Service, now
|
|
21
|
+
from .report import format_report, terminal_text, write_report
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Interrupted(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def interrupt(signum, frame):
|
|
29
|
+
raise Interrupted()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def model_upstream(value):
|
|
33
|
+
parsed = urlsplit(value)
|
|
34
|
+
if (
|
|
35
|
+
parsed.scheme != "http"
|
|
36
|
+
or parsed.hostname not in ("127.0.0.1", "localhost", "::1")
|
|
37
|
+
or not parsed.port
|
|
38
|
+
or parsed.username is not None
|
|
39
|
+
or parsed.path not in ("", "/")
|
|
40
|
+
or parsed.query
|
|
41
|
+
or parsed.fragment
|
|
42
|
+
):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"--model-upstream must be a local model server, e.g. http://127.0.0.1:8080"
|
|
45
|
+
)
|
|
46
|
+
return ("127.0.0.1" if parsed.hostname == "localhost" else parsed.hostname), parsed.port
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_workload(cmd, proxy, timeout, extra_env=None):
|
|
50
|
+
url = f"http://127.0.0.1:{proxy.server_port}"
|
|
51
|
+
env = dict(os.environ)
|
|
52
|
+
env.update({k: url for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")})
|
|
53
|
+
env.update(NO_PROXY="", no_proxy="")
|
|
54
|
+
env.update(extra_env or {})
|
|
55
|
+
old_handler = signal.signal(signal.SIGTERM, interrupt)
|
|
56
|
+
proc = None
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.Popen(cmd, env=env, start_new_session=True)
|
|
59
|
+
return proc.wait(timeout=timeout), False
|
|
60
|
+
except (subprocess.TimeoutExpired, KeyboardInterrupt, Interrupted):
|
|
61
|
+
print("Workload interrupted or timed out; stopping its process group.", file=sys.stderr)
|
|
62
|
+
return None, True
|
|
63
|
+
except OSError:
|
|
64
|
+
print("Could not launch workload. Check the executable and permissions.", file=sys.stderr)
|
|
65
|
+
return None, True
|
|
66
|
+
finally:
|
|
67
|
+
if proc is not None:
|
|
68
|
+
try:
|
|
69
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
70
|
+
except ProcessLookupError:
|
|
71
|
+
pass
|
|
72
|
+
proc.wait()
|
|
73
|
+
signal.signal(signal.SIGTERM, old_handler)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parser():
|
|
77
|
+
result = argparse.ArgumentParser(
|
|
78
|
+
prog="outfence",
|
|
79
|
+
description="Inspect proxy-routed connections. A local development tool, not a network sandbox.",
|
|
80
|
+
)
|
|
81
|
+
result.add_argument("--version", action="version", version=f"Outfence {__version__}")
|
|
82
|
+
sub = result.add_subparsers(dest="command", required=True)
|
|
83
|
+
init = sub.add_parser("init", help="Write a deny-all policy (or --demo policy)")
|
|
84
|
+
init.add_argument("path", nargs="?", default="outfence.json")
|
|
85
|
+
init.add_argument("--demo", action="store_true")
|
|
86
|
+
check = sub.add_parser("check", help="Validate a policy without making connections")
|
|
87
|
+
check.add_argument("path")
|
|
88
|
+
for name in ("demo", "run"):
|
|
89
|
+
p = sub.add_parser(
|
|
90
|
+
name,
|
|
91
|
+
help="Run synthetic local requests" if name == "demo" else "Run a proxy-aware command",
|
|
92
|
+
)
|
|
93
|
+
p.add_argument("--policy", required=name == "run")
|
|
94
|
+
p.add_argument("--mode", choices=["enforce", "observe"], default="enforce")
|
|
95
|
+
p.add_argument("--output", help="New report directory; existing directories are refused")
|
|
96
|
+
if name == "run":
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"--timeout", type=int, default=60, help="Workload timeout in seconds (default: 60)"
|
|
99
|
+
)
|
|
100
|
+
p.add_argument(
|
|
101
|
+
"--model-upstream",
|
|
102
|
+
metavar="URL",
|
|
103
|
+
help="Observe chat completions sent to this local OpenAI-compatible model server",
|
|
104
|
+
)
|
|
105
|
+
p.add_argument("workload", nargs=argparse.REMAINDER)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def notice(style, text, alarm=False):
|
|
110
|
+
if style.enabled:
|
|
111
|
+
text = " " + style.paint(text, console.ACID if alarm else style.dim)
|
|
112
|
+
print(text, flush=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def welcome(style):
|
|
116
|
+
"""A short menu for people who type `outfence` in a terminal with no arguments."""
|
|
117
|
+
columns = shutil.get_terminal_size((80, 24)).columns
|
|
118
|
+
print("\n".join(["", *console.banner(style, f"{__version__} alpha", columns), ""]))
|
|
119
|
+
choices = [
|
|
120
|
+
("1", "Watch Outfence block a request: the offline demo"),
|
|
121
|
+
("2", "Run your agent through Outfence"),
|
|
122
|
+
("3", "Create a deny-all policy here (outfence.json)"),
|
|
123
|
+
("q", "Quit"),
|
|
124
|
+
]
|
|
125
|
+
for key, text in choices:
|
|
126
|
+
print(f" {style.paint(key, console.ACID, bold=True)} {text}")
|
|
127
|
+
try:
|
|
128
|
+
choice = input("\n Choose 1, 2, 3 or q: ").strip().lower()
|
|
129
|
+
except (EOFError, KeyboardInterrupt):
|
|
130
|
+
print()
|
|
131
|
+
return 0
|
|
132
|
+
if choice == "1":
|
|
133
|
+
return main(["demo"])
|
|
134
|
+
if choice == "3":
|
|
135
|
+
return main(["init"])
|
|
136
|
+
if choice == "2":
|
|
137
|
+
print(
|
|
138
|
+
"\n Run any proxy-aware command against a policy:\n"
|
|
139
|
+
" outfence run --policy outfence.json -- python3 my_agent.py\n\n"
|
|
140
|
+
" Add your local model server to see who named each destination:\n"
|
|
141
|
+
" outfence run --policy outfence.json --model-upstream http://127.0.0.1:11434"
|
|
142
|
+
" -- python3 my_agent.py\n"
|
|
143
|
+
)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main(argv=None):
|
|
148
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
149
|
+
if not argv and sys.stdin.isatty() and sys.stdout.isatty():
|
|
150
|
+
return welcome(console.detect(sys.stdout))
|
|
151
|
+
args = parser().parse_args(argv)
|
|
152
|
+
style = console.detect(sys.stdout)
|
|
153
|
+
try:
|
|
154
|
+
if args.command == "init":
|
|
155
|
+
data = DEMO_POLICY if args.demo else {"version": 1, "allow": []}
|
|
156
|
+
with Path(args.path).open("x", encoding="utf-8") as stream:
|
|
157
|
+
json.dump(data, stream, indent=2)
|
|
158
|
+
stream.write("\n")
|
|
159
|
+
print(f"Policy created: {args.path}")
|
|
160
|
+
return 0
|
|
161
|
+
if args.command == "check":
|
|
162
|
+
policy = load_policy(args.path)
|
|
163
|
+
sources = ", ".join(policy.get("allow_from", []))
|
|
164
|
+
named = f"; also hosts named by: {sources}" if sources else ""
|
|
165
|
+
print(f"Valid policy: {len(policy['allow'])} exact destination rules{named}")
|
|
166
|
+
return 0
|
|
167
|
+
if os.name != "posix":
|
|
168
|
+
raise ValueError("This alpha supports macOS and Linux only.")
|
|
169
|
+
policy = load_policy(args.policy) if args.policy else DEMO_POLICY
|
|
170
|
+
upstream = None
|
|
171
|
+
if args.command == "run":
|
|
172
|
+
cmd = args.workload[1:] if args.workload[:1] == ["--"] else args.workload
|
|
173
|
+
if not cmd or args.timeout < 1:
|
|
174
|
+
raise ValueError("Provide a positive timeout and a command after --.")
|
|
175
|
+
if args.model_upstream:
|
|
176
|
+
upstream = model_upstream(args.model_upstream)
|
|
177
|
+
output = (
|
|
178
|
+
Path(args.output) if args.output else Path("runs") / ("run-" + uuid.uuid4().hex[:10])
|
|
179
|
+
)
|
|
180
|
+
# Validate/reserve the output before starting a potentially side-effectful command.
|
|
181
|
+
output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
|
182
|
+
coverage = "proxy + model tap" if upstream else "proxy only"
|
|
183
|
+
columns = shutil.get_terminal_size((80, 24)).columns
|
|
184
|
+
honest = "Direct connections can bypass this proxy. No OS/container isolation."
|
|
185
|
+
if style.enabled:
|
|
186
|
+
status = f"{__version__} alpha | {args.mode} | {coverage}"
|
|
187
|
+
print("\n".join(["", *console.banner(style, status, columns), ""]), flush=True)
|
|
188
|
+
else:
|
|
189
|
+
print(f"Outfence {__version__}, local proxy alpha, coverage: {coverage}", flush=True)
|
|
190
|
+
notice(style, honest)
|
|
191
|
+
if args.mode == "observe":
|
|
192
|
+
notice(style, "OBSERVE: out-of-policy proxy requests may connect.", alarm=True)
|
|
193
|
+
if policy.get("allow_from") and not upstream:
|
|
194
|
+
notice(style, "allow_from needs --model-upstream; only exact allow rules apply.", True)
|
|
195
|
+
live = console.Live(style, sys.stdout) if style.enabled else None
|
|
196
|
+
started, incomplete, workload_exit, session = now(), False, 0, None
|
|
197
|
+
if args.command == "demo":
|
|
198
|
+
fixture = LoopbackHTTPServer(("127.0.0.1", 0), Fixture)
|
|
199
|
+
fixture.received = []
|
|
200
|
+
with Service(fixture):
|
|
201
|
+
routes = {
|
|
202
|
+
(host, 80): fixture.server_address
|
|
203
|
+
for host in ("model.example", "retrieval.example", "telemetry.example")
|
|
204
|
+
}
|
|
205
|
+
announce = live.decision if live else None
|
|
206
|
+
with Service(Proxy(policy, args.mode, routes, on_decision=announce)) as proxy:
|
|
207
|
+
try:
|
|
208
|
+
demo_requests(proxy.server_port)
|
|
209
|
+
except (OSError, KeyboardInterrupt):
|
|
210
|
+
incomplete = True
|
|
211
|
+
print(
|
|
212
|
+
f" Fixture received {len(fixture.received)} connections (destination-side evidence)."
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
session = Session(on_change=live.change if live else None) if upstream else None
|
|
216
|
+
announce = live.decision if live else None
|
|
217
|
+
with (
|
|
218
|
+
Service(ModelTap(upstream, session)) if upstream else nullcontext() as tap,
|
|
219
|
+
Service(Proxy(policy, args.mode, session=session, on_decision=announce)) as proxy,
|
|
220
|
+
):
|
|
221
|
+
env = {}
|
|
222
|
+
if tap is not None:
|
|
223
|
+
base = f"http://127.0.0.1:{tap.server_port}/v1"
|
|
224
|
+
env = {"OPENAI_BASE_URL": base, "OUTFENCE_MODEL_URL": base}
|
|
225
|
+
notice(style, f"Model tap: {base} -> {args.model_upstream}")
|
|
226
|
+
workload_exit, incomplete = run_workload(cmd, proxy, args.timeout, env)
|
|
227
|
+
report = write_report(
|
|
228
|
+
output,
|
|
229
|
+
args.mode,
|
|
230
|
+
policy,
|
|
231
|
+
proxy.snapshot(),
|
|
232
|
+
started,
|
|
233
|
+
workload_exit,
|
|
234
|
+
demo=args.command == "demo",
|
|
235
|
+
incomplete=incomplete or proxy.incomplete or bool(session and session.errors),
|
|
236
|
+
reserved=True,
|
|
237
|
+
dropped_events=proxy.dropped_events,
|
|
238
|
+
model_tap=session.summary() if session else None,
|
|
239
|
+
)
|
|
240
|
+
evidence = terminal_text((output / "report.json").resolve())
|
|
241
|
+
if style.enabled:
|
|
242
|
+
print(console.render(style, report, evidence, columns))
|
|
243
|
+
else:
|
|
244
|
+
print(format_report(report))
|
|
245
|
+
print(f"JSON: {evidence}")
|
|
246
|
+
return report["exit_code"]
|
|
247
|
+
except (OSError, ValueError) as exc:
|
|
248
|
+
print(f"Outfence setup error: {exc}", file=sys.stderr)
|
|
249
|
+
return 3
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
sys.exit(main())
|
outfence/console.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Interactive terminal look: brand colors, the Capitoline goose and live decisions.
|
|
2
|
+
|
|
3
|
+
Styling applies only when stdout is a terminal, or when OUTFENCE_COLOR=always. NO_COLOR,
|
|
4
|
+
OUTFENCE_COLOR=never, pipes, files and CI get the plain report, which carries the same facts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from .mascot import MARK, POSES
|
|
14
|
+
from .report import EXIT_REASONS, explain, terminal_text
|
|
15
|
+
|
|
16
|
+
# Copied from brand/tokens.json; tests/test_console.py keeps them identical.
|
|
17
|
+
INK, PAPER, ACID, MIST, SLATE = "#171917", "#FAFAF7", "#D9F378", "#C2C6BC", "#656B63"
|
|
18
|
+
# On dark terminals the outlines turn dark grey and the white body carries the shape.
|
|
19
|
+
DARK_OUTLINE, DARK_SHADOW, LIGHT_SHADOW = "#5A5F58", "#30332F", "#D0D4CB"
|
|
20
|
+
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
21
|
+
GLYPHS = {
|
|
22
|
+
"o": [".....", ".....", ".###.", "#...#", "#...#", "#...#", ".###."],
|
|
23
|
+
"u": [".....", ".....", "#...#", "#...#", "#...#", "#..##", ".##.#"],
|
|
24
|
+
"t": [".#...", ".#...", "####.", ".#...", ".#...", ".#..#", "..##."],
|
|
25
|
+
"f": ["..###", ".#...", "####.", ".#...", ".#...", ".#...", ".#..."],
|
|
26
|
+
"e": [".....", ".....", ".###.", "#...#", "#####", "#....", ".###."],
|
|
27
|
+
"n": [".....", ".....", "####.", "#...#", "#...#", "#...#", "#...#"],
|
|
28
|
+
"c": [".....", ".....", ".###.", "#...#", "#....", "#...#", ".###."],
|
|
29
|
+
}
|
|
30
|
+
LABELS = {"allowed": "ALLOWED", "blocked": "BLOCKED", "would_block": "WOULD BLOCK"}
|
|
31
|
+
WIDTH = 13
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def xterm(r, g, b):
|
|
35
|
+
"""Nearest xterm-256 color, for terminals without 24-bit color."""
|
|
36
|
+
if max(r, g, b) - min(r, g, b) < 16:
|
|
37
|
+
level = (r + g + b) // 3
|
|
38
|
+
return 16 if level < 8 else 231 if level > 247 else 232 + (level - 8) // 10
|
|
39
|
+
return 16 + 36 * round(r / 51) + 6 * round(g / 51) + round(b / 51)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Style:
|
|
43
|
+
def __init__(self, enabled=False, truecolor=True, light=False):
|
|
44
|
+
self.enabled, self.truecolor, self.light = enabled, truecolor, light
|
|
45
|
+
self.text = INK if light else PAPER
|
|
46
|
+
self.dim = SLATE if light else MIST
|
|
47
|
+
|
|
48
|
+
def color(self, hex_color, layer):
|
|
49
|
+
r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))
|
|
50
|
+
if self.truecolor:
|
|
51
|
+
return f"\x1b[{layer};2;{r};{g};{b}m"
|
|
52
|
+
return f"\x1b[{layer};5;{xterm(r, g, b)}m"
|
|
53
|
+
|
|
54
|
+
def paint(self, text, fg=None, bg=None, bold=False):
|
|
55
|
+
if not self.enabled:
|
|
56
|
+
return text
|
|
57
|
+
codes = (self.color(fg, 38) if fg else "") + (self.color(bg, 48) if bg else "")
|
|
58
|
+
codes += "\x1b[1m" if bold else ""
|
|
59
|
+
return f"{codes}{text}\x1b[0m" if codes else text
|
|
60
|
+
|
|
61
|
+
def palette(self, art):
|
|
62
|
+
if art == "mark":
|
|
63
|
+
return (
|
|
64
|
+
{"k": INK, "w": PAPER, "a": ACID}
|
|
65
|
+
if self.light
|
|
66
|
+
else {"k": PAPER, "w": INK, "a": ACID}
|
|
67
|
+
)
|
|
68
|
+
if self.light:
|
|
69
|
+
return {"k": INK, "w": PAPER, "a": ACID, "s": LIGHT_SHADOW}
|
|
70
|
+
return {"k": DARK_OUTLINE, "w": PAPER, "a": ACID, "s": DARK_SHADOW}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect(stream, environ=None):
|
|
74
|
+
environ = os.environ if environ is None else environ
|
|
75
|
+
choice = environ.get("OUTFENCE_COLOR", "auto").lower()
|
|
76
|
+
tty = getattr(stream, "isatty", lambda: False)()
|
|
77
|
+
if environ.get("NO_COLOR") or choice == "never":
|
|
78
|
+
return Style()
|
|
79
|
+
if choice != "always" and (not tty or environ.get("TERM") == "dumb"):
|
|
80
|
+
return Style()
|
|
81
|
+
truecolor = environ.get("COLORTERM", "").lower() in ("truecolor", "24bit")
|
|
82
|
+
theme = environ.get("OUTFENCE_THEME", "").lower()
|
|
83
|
+
background = environ.get("COLORFGBG", "").rsplit(";", 1)[-1]
|
|
84
|
+
return Style(
|
|
85
|
+
True, truecolor, theme == "light" or (theme != "dark" and background in ("7", "15"))
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def visible(text):
|
|
90
|
+
return len(ANSI.sub("", text))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decode(art):
|
|
94
|
+
return [
|
|
95
|
+
"".join(c * int(n or 1) for n, c in re.findall(r"(\d*)([kwas.])", row))
|
|
96
|
+
for row in art.split("|")
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def crop(rows):
|
|
101
|
+
used = [i for i, row in enumerate(rows) if set(row) != {"."}]
|
|
102
|
+
cols = [j for j in range(len(rows[0])) if any(row[j] != "." for row in rows)]
|
|
103
|
+
return [row[cols[0] : cols[-1] + 1] for row in rows[used[0] : used[-1] + 1]]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def pixels(style, rows, palette):
|
|
107
|
+
"""Two tile rows per line: the upper half block takes the foreground, the lower the background."""
|
|
108
|
+
if len(rows) % 2:
|
|
109
|
+
rows = rows + ["." * len(rows[0])]
|
|
110
|
+
lines = []
|
|
111
|
+
for top, bottom in zip(rows[0::2], rows[1::2]):
|
|
112
|
+
cells = []
|
|
113
|
+
for a, b in zip(top, bottom):
|
|
114
|
+
if a == b == ".":
|
|
115
|
+
cells.append((" ", None, None))
|
|
116
|
+
elif b == ".":
|
|
117
|
+
cells.append(("▀", palette[a], None))
|
|
118
|
+
elif a == ".":
|
|
119
|
+
cells.append(("▄", palette[b], None))
|
|
120
|
+
else:
|
|
121
|
+
cells.append(("▀", palette[a], palette[b]))
|
|
122
|
+
line, start = "", 0
|
|
123
|
+
for index in range(1, len(cells) + 1):
|
|
124
|
+
if index == len(cells) or cells[index][1:] != cells[start][1:]:
|
|
125
|
+
text = "".join(cell[0] for cell in cells[start:index])
|
|
126
|
+
line += style.paint(text, *cells[start][1:]) if cells[start][1] else text
|
|
127
|
+
start = index
|
|
128
|
+
lines.append(line.rstrip())
|
|
129
|
+
return lines
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def wordmark(style):
|
|
133
|
+
rows = [""] * 7
|
|
134
|
+
for index, letter in enumerate("outfence"):
|
|
135
|
+
rows = [row + ("." if index else "") + glyph for row, glyph in zip(rows, GLYPHS[letter])]
|
|
136
|
+
return pixels(style, [row.replace("#", "k") for row in rows], {"k": style.text})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def side_by_side(left, right, gap=3):
|
|
140
|
+
width = max((visible(line) for line in left), default=0)
|
|
141
|
+
height = max(len(left), len(right))
|
|
142
|
+
left, right = left + [""] * (height - len(left)), right + [""] * (height - len(right))
|
|
143
|
+
return [f" {a}{' ' * (width - visible(a) + gap)}{b}".rstrip() for a, b in zip(left, right)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def banner(style, status, columns):
|
|
147
|
+
if columns < 72:
|
|
148
|
+
return [
|
|
149
|
+
f" {style.paint('outfence', style.text, bold=True)} {style.paint(status, style.dim)}"
|
|
150
|
+
]
|
|
151
|
+
right = wordmark(style) + [
|
|
152
|
+
style.paint("Know where your agents connect.", style.dim),
|
|
153
|
+
style.paint(status, style.dim),
|
|
154
|
+
]
|
|
155
|
+
return side_by_side(pixels(style, decode(MARK), style.palette("mark")), right)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def label(style, action):
|
|
159
|
+
word = LABELS.get(action, terminal_text(action).upper())
|
|
160
|
+
if action == "blocked":
|
|
161
|
+
return style.paint(f" {word} ", INK, ACID, bold=True) + " " * (WIDTH - len(word) - 2)
|
|
162
|
+
if action == "would_block":
|
|
163
|
+
return style.paint(word.ljust(WIDTH), ACID, bold=True)
|
|
164
|
+
return style.paint(word.ljust(WIDTH), style.dim)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def destination(event):
|
|
168
|
+
host = event["host"] or "unknown"
|
|
169
|
+
return terminal_text(
|
|
170
|
+
f"[{host}]:{event['port']}" if ":" in host else f"{host}:{event['port'] or '?'}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Live:
|
|
175
|
+
"""Prints decisions and tool changes as they happen. Proxy and tap threads call it."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, style, stream):
|
|
178
|
+
self.style, self.stream, self.lock = style, stream, threading.Lock()
|
|
179
|
+
|
|
180
|
+
def line(self, kind, text):
|
|
181
|
+
stamp = self.style.paint(time.strftime("%H:%M:%S"), self.style.dim)
|
|
182
|
+
with self.lock:
|
|
183
|
+
print(f" {stamp} {kind}{text}", file=self.stream, flush=True)
|
|
184
|
+
|
|
185
|
+
def decision(self, event):
|
|
186
|
+
reason = self.style.paint(terminal_text(event["reason"]), self.style.dim)
|
|
187
|
+
self.line(label(self.style, event["action"]), f"{destination(event)} {reason}")
|
|
188
|
+
|
|
189
|
+
def change(self, note):
|
|
190
|
+
kind = self.style.paint(note["kind"].replace("_", " ").ljust(WIDTH), self.style.dim)
|
|
191
|
+
if note["kind"] == "tools":
|
|
192
|
+
names = [f"+{n}" for n in note["added"]] + [f"-{n}" for n in note["removed"]]
|
|
193
|
+
self.line(kind, terminal_text(" ".join(names)))
|
|
194
|
+
else:
|
|
195
|
+
hosts = self.style.paint(terminal_text(", ".join(note["hosts"])), self.style.dim)
|
|
196
|
+
self.line(kind, f"{terminal_text(note['name'] or 'unnamed')} {hosts}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def pose_for(report):
|
|
200
|
+
actions = {event["action"] for event in report["events"]}
|
|
201
|
+
if "blocked" in actions:
|
|
202
|
+
return "blocking"
|
|
203
|
+
if "would_block" in actions or report["exit_code"] == 3:
|
|
204
|
+
return "alarm"
|
|
205
|
+
return "approving" if report["events"] else "idle"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def plural(count, word):
|
|
209
|
+
return f"{count} {word}{'' if count == 1 else 's'}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def headline(style, report):
|
|
213
|
+
events = report["events"]
|
|
214
|
+
blocked = sum(e["action"] == "blocked" for e in events)
|
|
215
|
+
would = sum(e["action"] == "would_block" for e in events)
|
|
216
|
+
model = sum(e.get("origin") == "model" and e["action"] != "allowed" for e in events)
|
|
217
|
+
by_model = f", {model} named only by the model" if model else ""
|
|
218
|
+
if blocked:
|
|
219
|
+
return f"{label(style, 'blocked')}{plural(blocked, 'destination')} outside your policy{by_model}"
|
|
220
|
+
if would:
|
|
221
|
+
text = f"{plural(would, 'destination')} outside your policy, let through by observe mode"
|
|
222
|
+
return f"{label(style, 'would_block')}{text}{by_model}"
|
|
223
|
+
if report["exit_code"] == 3:
|
|
224
|
+
return f"{style.paint('INCOMPLETE'.ljust(WIDTH), ACID, bold=True)}evidence may be missing"
|
|
225
|
+
if events:
|
|
226
|
+
return (
|
|
227
|
+
f"{label(style, 'allowed')}{plural(len(events), 'destination')}, all within your policy"
|
|
228
|
+
)
|
|
229
|
+
return f"{style.paint('IDLE'.ljust(WIDTH), style.dim)}no proxy requests observed"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render(style, report, evidence, columns, environ=None):
|
|
233
|
+
"""The styled report: the same facts as format_report, plus the goose for the outcome."""
|
|
234
|
+
environ = os.environ if environ is None else environ
|
|
235
|
+
dim = style.dim
|
|
236
|
+
events, tap, coverage = report["events"], report.get("model_tap"), report["coverage"]
|
|
237
|
+
counts = {
|
|
238
|
+
a: sum(e["action"] == a for e in events) for a in ("allowed", "blocked", "would_block")
|
|
239
|
+
}
|
|
240
|
+
exit_code = report["exit_code"]
|
|
241
|
+
workload = report["workload_exit_code"]
|
|
242
|
+
summary = [
|
|
243
|
+
"",
|
|
244
|
+
headline(style, report),
|
|
245
|
+
"",
|
|
246
|
+
f"allowed {counts['allowed']} | blocked {counts['blocked']} | would block {counts['would_block']}",
|
|
247
|
+
style.paint(
|
|
248
|
+
f"mode {report['mode']} | coverage {coverage['status']}"
|
|
249
|
+
f" | provenance {coverage.get('provenance', 'unavailable')}",
|
|
250
|
+
dim,
|
|
251
|
+
),
|
|
252
|
+
"",
|
|
253
|
+
f"{style.paint(f'Exit {exit_code}', style.text, bold=True)} {EXIT_REASONS[exit_code]}",
|
|
254
|
+
style.paint(
|
|
255
|
+
f"workload exit {workload if workload is not None else 'unavailable'}"
|
|
256
|
+
" | task correctness not evaluated",
|
|
257
|
+
dim,
|
|
258
|
+
),
|
|
259
|
+
style.paint(f"evidence {evidence}", dim),
|
|
260
|
+
]
|
|
261
|
+
goose = []
|
|
262
|
+
if environ.get("OUTFENCE_GOOSE", "on").lower() not in ("off", "0", "no"):
|
|
263
|
+
goose = pixels(style, crop(decode(POSES[pose_for(report)])), style.palette("pose"))
|
|
264
|
+
width = max((visible(line) for line in goose), default=0)
|
|
265
|
+
if goose and columns >= width + 5 + max(visible(line) for line in summary):
|
|
266
|
+
lines = side_by_side(goose, summary)
|
|
267
|
+
elif goose and columns >= width + 4:
|
|
268
|
+
lines = [f" {line}" for line in goose] + [f" {line}" for line in summary]
|
|
269
|
+
else:
|
|
270
|
+
lines = [f" {line}" for line in summary]
|
|
271
|
+
lines.append("")
|
|
272
|
+
if tap is not None:
|
|
273
|
+
lines.append(
|
|
274
|
+
" "
|
|
275
|
+
+ style.paint("model tap".ljust(WIDTH), dim)
|
|
276
|
+
+ f"{plural(tap['requests'], 'chat request')} | {plural(len(tap['tool_calls']), 'tool call')}"
|
|
277
|
+
+ f" | {tap['rejected']} rejected | {tap['errors']} errors"
|
|
278
|
+
)
|
|
279
|
+
for change in tap["environment"]:
|
|
280
|
+
names = [f"+{n}" for n in change["added"]] + [f"-{n}" for n in change["removed"]]
|
|
281
|
+
if names:
|
|
282
|
+
when = datetime.fromisoformat(change["time"]).astimezone().strftime("%H:%M:%S")
|
|
283
|
+
kind = style.paint("tools".ljust(WIDTH), dim)
|
|
284
|
+
lines.append(f" {kind}{when} {terminal_text(' '.join(names))}")
|
|
285
|
+
lines.append("")
|
|
286
|
+
for event in events:
|
|
287
|
+
reason = f"{terminal_text(event['reason'])} | {terminal_text(event['connection'])}"
|
|
288
|
+
lines.append(
|
|
289
|
+
f" {label(style, event['action'])}{destination(event)} {style.paint(reason, dim)}"
|
|
290
|
+
)
|
|
291
|
+
lines.extend(" " * (WIDTH + 2) + style.paint(line.strip(), dim) for line in explain(event))
|
|
292
|
+
if not events:
|
|
293
|
+
lines.append(
|
|
294
|
+
" "
|
|
295
|
+
+ style.paint(
|
|
296
|
+
"No proxy requests observed. This does not prove there was no network activity.",
|
|
297
|
+
dim,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
lines += [
|
|
301
|
+
"",
|
|
302
|
+
" "
|
|
303
|
+
+ style.paint(
|
|
304
|
+
f"Dropped events: {coverage['dropped_events']}. Direct connections bypass this proxy."
|
|
305
|
+
" Encrypted contents are not inspected.",
|
|
306
|
+
dim,
|
|
307
|
+
),
|
|
308
|
+
]
|
|
309
|
+
return "\n".join(lines)
|
outfence/core.py
ADDED
outfence/demo.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Synthetic services used only by demo and offline tests."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
from http.server import BaseHTTPRequestHandler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Fixture(BaseHTTPRequestHandler):
|
|
8
|
+
def log_message(self, *args):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
def do_GET(self):
|
|
12
|
+
self.server.received.append(self.headers.get("Host"))
|
|
13
|
+
body = b'{"ok":true,"fixture":"local synthetic service"}\n'
|
|
14
|
+
self.send_response(200)
|
|
15
|
+
self.send_header("Content-Length", str(len(body)))
|
|
16
|
+
self.end_headers()
|
|
17
|
+
self.wfile.write(body)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def demo_requests(port):
|
|
21
|
+
statuses = []
|
|
22
|
+
for host in ("model.example", "retrieval.example", "telemetry.example"):
|
|
23
|
+
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
|
24
|
+
conn.request("GET", f"http://{host}/demo")
|
|
25
|
+
response = conn.getresponse()
|
|
26
|
+
statuses.append(response.status)
|
|
27
|
+
response.read()
|
|
28
|
+
conn.close()
|
|
29
|
+
print(f" {host:24} HTTP {response.status}")
|
|
30
|
+
return statuses
|
outfence/mascot.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Capitoline goose tile art for the terminal, copied from brand/mascot.json.
|
|
2
|
+
|
|
3
|
+
Rows are run-length encoded and separated by |: k ink, w white, a acid, s shadow, . empty.
|
|
4
|
+
tests/test_console.py keeps this copy identical to the brand file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
MARK = "3.k.k.k6.|4.3k7.|3.6k5.|2.8k4.|2.6kw2k3.|2.9k3a|2.9k2a.|3.7k4.|3.6k5.|3.4k7.|2.5k7.|2.4k8.|.5k8.|.4k9."
|
|
8
|
+
POSES = {
|
|
9
|
+
"watching": "66.|66.|66.|66.|38.k2.k24.|36.k2.k.k24.|37.2k.k25.|38.k.2k24.|38.6k22.|37.2k4w2k21.|36.2k6w2k20.|36.2k4wkw5a17.|36.k7w6a16.|36.k7w7a15.|35.3k6w5a17.|35.4k4w2k21.|34.2kw8k21.|34.2k3w4k23.|34.k5w2k24.|34.k5wk25.|33.2k4w2k25.|33.2k4w2k25.|33.2k4w2k25.|33.2k4wk26.|33.2k4wk26.|33.2k4wk26.|24.10k5wk26.|7.2k11.16k3wk26.|7.3k8.4k12w4kwk26.|8.3k3.5k18w3k26.|8.4k2.18k6w3k25.|8.2kw14k5w4k5w2k25.|9.k2w5k16w2k5w2k24.|9.2kw2k2w2k16wk5w2k24.|10.3k3w3k15w2k5wk24.|11.2k4w3k14w2k4w2k24.|11.2k6w2k13w2k4w2k24.|11.2k7w5k7w3k4w2k25.|12.k9w12k4w2k26.|12.2k23w2k27.|13.2k21w2k28.|14.3k16w4k29.|16.4k10w5k31.|18.14k34.|23.2k4.2k35.|24.k4.2k35.|23.ka4.2k35.|23.4a2.4a33.|11.12s4a2s5a11s21.|15.27s24.|66.|66.|66.",
|
|
10
|
+
"alarm": "66.|66.|66.|66.|38.k2.k24.|36.k2.k.k24.|37.2k.k25.|38.k.2k24.|38.6k22.|37.2k4w2k14.a6.|36.2k6w2k14.a5.|36.2k4wkw4a9.a2.a5.|36.k7w6a7.a2.a5.|36.k8w5a7.a2.a5.|35.3k6w2k11.a2.a5.|35.4k4wk6a7.a2.a5.|34.2kw7k.3a9.a2.a5.|34.2k3w4k17.a5.|34.k5w2k17.a6.|34.k5wk25.|33.2k4w2k25.|33.2k4w2k25.|33.2k4w2k25.|33.2k4wk26.|33.2k4wk26.|33.2k4wk26.|24.10k5wk26.|7.2k11.16k3wk26.|7.3k8.4k12w4kwk26.|8.3k3.5k18w3k26.|8.4k2.18k6w3k25.|8.2kw14k5w4k5w2k25.|9.k2w5k16w2k5w2k24.|9.2kw2k2w2k16wk5w2k24.|10.3k3w3k15w2k5wk24.|11.2k4w3k14w2k4w2k24.|11.2k6w2k13w2k4w2k24.|11.2k7w5k7w3k4w2k25.|12.k9w12k4w2k26.|12.2k23w2k27.|13.2k21w2k28.|14.3k16w4k29.|16.4k10w5k31.|18.14k34.|23.2k4.2k35.|24.k4.2k35.|23.ka4.2k35.|23.4a2.4a33.|11.12s4a2s5a11s21.|15.27s24.|66.|66.|66.",
|
|
11
|
+
"blocking": "66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|19.2k45.|18.5k43.|18.2kw3k42.|18.k4w3k40.|18.kw2k3w2k39.|17.2k3wk3w2k38.|17.2k4w2k2w2k21.k15.|17.2k6wk2w2k18.k.k15.|17.2k7w2kw2k14.k3.k16.|17.k10wkw2k15.k.k16.|17.k12w2k15.6k10.k2.|17.k13w2k13.2k4w2k7.2k3.|6.2k7.4k12w3k8.5k6w2a11.|6.3k4.3kw2k13w15k3w2kw5a8.|6.5k.2k3w3k12w5k8w2k6w6a7.|7.k2w3k5w7k3w5k3w2k7w2k6w2k11.|7.2kw2k9w10k6w2k7w2k4wk6a7.|8.kw2k26wk7w3k2w2k4a9.|8.3k27w14k9.2k3.|9.3k27w2k22.k2.|10.2k27wk26.|11.2k25w2k26.|11.2k25wk27.|12.3k22w2k27.|14.2k19w3k28.|15.4k14w4k29.|17.7k5w6k31.|20.12k34.|20.k9.k35.|19.2k9.2k34.|19.2k9.2k34.|18.3a9.s3a32.|8.9s5a9s5a9s21.|12.29s25.|66.|66.|66.",
|
|
12
|
+
"approving": "66.|66.|66.|66.|38.k2.k24.|36.k2.k.k24.|37.2k.k25.|38.k.2k24.|38.6k22.|37.2k4w2k21.|36.2k6w2k20.|36.2k4wkw5a17.|36.k7w6a16.|36.k7w7a15.|35.3k6w5a17.|35.4k4w2k21.|34.2kw8k21.|34.2k3w4k23.|34.k5w2k24.|34.k5wk17.2k6.|33.2k4w2k17.k7.|33.2k4w2k16.k8.|33.2k4w2k15.2k8.|33.2k4wk9.2k4.2k9.|33.2k4wk10.2k3.k10.|33.2k4wk11.2k.k11.|24.10k5wk12.2k12.|7.2k11.16k3wk13.k12.|7.3k8.4k12w4kwk26.|8.3k3.5k18w3k26.|8.4k2.18k6w3k25.|8.2kw14k5w4k5w2k25.|9.k2w5k16w2k5w2k24.|9.2kw2k2w2k16wk5w2k24.|10.3k3w3k15w2k5wk24.|11.2k4w3k14w2k4w2k24.|11.2k6w2k13w2k4w2k24.|11.2k7w5k7w3k4w2k25.|12.k9w12k4w2k26.|12.2k23w2k27.|13.2k21w2k28.|14.3k16w4k29.|16.4k10w5k31.|18.14k34.|23.2k4.2k35.|24.k4.2k35.|23.ka4.2k35.|23.4a2.4a33.|11.12s4a2s5a11s21.|15.27s24.|66.|66.|66.",
|
|
13
|
+
"idle": "66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|66.|39.k26.|36.2k2.k.k23.|38.k.k.k23.|39.4k23.|38.7k21.|37.2k5w2k20.|37.k6w2k20.|36.2k4wkw4a18.|36.2k6w6a16.|36.2k6w6a16.|36.2k6wk3a18.|35.5k3w2k21.|35.k2w6k22.|35.k4w2k24.|35.k4w2k24.|35.k4w2k24.|35.k4w2k24.|35.k4w2k24.|26.10k4w2k24.|9.k5.k5.18kw2k24.|9.2k4.18k4w5k24.|9.4k2.11k5w4k5w3k23.|9.2kw6k16w2k6w2k22.|10.2kw3kw2k16wk7w2k21.|10.2kwk3w3k15w2k6w2k21.|11.3k4w3k14w2k6w2k21.|12.2k6w2k13w2k6w2k21.|12.2k7w5k7w3k6w2k22.|13.2k8w12k6w2k23.|14.2k23w3k24.|15.4k17w4k26.|17.7k6w8k28.|11.10s13k14s18.|15.29s22.|66.|66.|66.",
|
|
14
|
+
}
|