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 ADDED
@@ -0,0 +1,3 @@
1
+ """Outfence: local destination policy and connection reports."""
2
+
3
+ __version__ = "0.1.0a2"
outfence/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support python -m outfence."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
@@ -0,0 +1,7 @@
1
+ """Compatibility imports for early prototype users. Prefer dedicated modules."""
2
+
3
+ from .policy import load_policy
4
+ from .proxy import Proxy, Service, now
5
+ from .report import write_report
6
+
7
+ __all__ = ["load_policy", "Proxy", "Service", "now", "write_report"]
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
+ }