aegis-sandbox 0.8.0__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 (33) hide show
  1. aegis_sandbox-0.8.0/LICENSE +21 -0
  2. aegis_sandbox-0.8.0/PKG-INFO +91 -0
  3. aegis_sandbox-0.8.0/README.md +71 -0
  4. aegis_sandbox-0.8.0/aegis/__init__.py +53 -0
  5. aegis_sandbox-0.8.0/aegis/approval.py +219 -0
  6. aegis_sandbox-0.8.0/aegis/audit.py +495 -0
  7. aegis_sandbox-0.8.0/aegis/broker.py +454 -0
  8. aegis_sandbox-0.8.0/aegis/checkpoint.py +180 -0
  9. aegis_sandbox-0.8.0/aegis/cli.py +628 -0
  10. aegis_sandbox-0.8.0/aegis/clients.py +472 -0
  11. aegis_sandbox-0.8.0/aegis/dlp.py +93 -0
  12. aegis_sandbox-0.8.0/aegis/doctor.py +1512 -0
  13. aegis_sandbox-0.8.0/aegis/egress.py +284 -0
  14. aegis_sandbox-0.8.0/aegis/fetch.py +641 -0
  15. aegis_sandbox-0.8.0/aegis/killswitch.py +136 -0
  16. aegis_sandbox-0.8.0/aegis/launcher.py +559 -0
  17. aegis_sandbox-0.8.0/aegis/onboard.py +857 -0
  18. aegis_sandbox-0.8.0/aegis/policy.py +832 -0
  19. aegis_sandbox-0.8.0/aegis/policy.template.json +32 -0
  20. aegis_sandbox-0.8.0/aegis/policyedit.py +634 -0
  21. aegis_sandbox-0.8.0/aegis/proxy.py +787 -0
  22. aegis_sandbox-0.8.0/aegis/sandbox.py +602 -0
  23. aegis_sandbox-0.8.0/aegis/trash.py +461 -0
  24. aegis_sandbox-0.8.0/aegis/verify.py +695 -0
  25. aegis_sandbox-0.8.0/aegis/violations.py +657 -0
  26. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/PKG-INFO +91 -0
  27. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/SOURCES.txt +31 -0
  28. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/dependency_links.txt +1 -0
  29. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/entry_points.txt +6 -0
  30. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/requires.txt +3 -0
  31. aegis_sandbox-0.8.0/aegis_sandbox.egg-info/top_level.txt +1 -0
  32. aegis_sandbox-0.8.0/pyproject.toml +58 -0
  33. aegis_sandbox-0.8.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adarsh14734
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: aegis-sandbox
3
+ Version: 0.8.0
4
+ Summary: MCP-layer policy proxy with a tamper-evident audit trail
5
+ Author: Adarsh
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Adarsh14734/Aegis
8
+ Keywords: mcp,policy,audit,agent,security
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: keyring
18
+ Requires-Dist: keyring>=24; extra == "keyring"
19
+ Dynamic: license-file
20
+
21
+ # Aegis
22
+
23
+ **Claude Code's sandbox lets an agent read your SSH keys and AWS credentials by default. Aegis doesn't.**
24
+
25
+ Two layers, both verified on real hardware.
26
+
27
+ Kernel sandbox — the agent's own shell cannot reach a denied path:
28
+
29
+ $ ! cat ~/.ssh/id_rsa
30
+ cat: /Users/you/.ssh/id_rsa: Operation not permitted
31
+
32
+ $ ! cat ~/.aws/credentials
33
+ cat: /Users/you/.aws/credentials: Operation not permitted
34
+
35
+ $ tail ~/Library/Application\ Support/Aegis/denials.log
36
+ kernel denied file-read-data /Users/you/.ssh/id_rsa to cat(pid 41560)
37
+ kernel denied file-read-data /Users/you/.aws/credentials to cat(pid 42180)
38
+
39
+ MCP proxy — same tool, same file, with and without Aegis in front:
40
+
41
+ direct to the server: allowed: TOKEN=proof-env-secret
42
+ through aegis proxy: AEGIS DENIED: read_text_file
43
+ Reason: path matches deny rule '.env'
44
+ Rule: deny_paths
45
+
46
+ ## What it does
47
+
48
+ Sits between your AI coding agent and your machine:
49
+
50
+ - **Deny by default** on every tool call
51
+ - **Kernel sandbox** on subprocesses — `cat .env` can't bypass it
52
+ - **Tamper-evident audit log** — hash-chained, integrity checked by `aegis doctor`, and checkpointed
53
+ from outside the sandbox under a key the sandbox can't read or write
54
+ - **Outbound requests checked** before they're made
55
+ - **Secrets never reach the MCP server**
56
+
57
+ ## Install (macOS Apple Silicon)
58
+
59
+ New here? [docs/getting-started.md](docs/getting-started.md) is the step-by-step path, including the two questions that default to No.
60
+
61
+ python3 -m pip install aegis-sandbox
62
+ aegis init # detects Claude Code / Cursor, asks a few questions
63
+ aegis doctor # proves the boundary is actually in place
64
+
65
+ **Upgrading?** Re-run `aegis init`. Your `policy.json` is yours and is never rewritten behind your back, so a
66
+ new sandbox domain does not appear on its own — and without the OAuth token endpoint a sandboxed client stops
67
+ working when its token expires. `aegis init` offers the new hosts; accepting is one keystroke.
68
+
69
+ Prefer an app? [Download the .dmg](https://github.com/Adarsh14734/Aegis/releases/download/v0.6.0/Aegis_0.6.0_aarch64.dmg)
70
+
71
+ `SHA256: bcccaa957fd3a0a15413eb1207a012f0328e309d078e7b7f2af853915e64c6dc`
72
+
73
+ Unsigned build — right-click the app → Open the first time (macOS will warn about an unidentified developer, that's expected). Or build from source.
74
+
75
+ ## What it does NOT do
76
+
77
+ - Does not stop prompt injection
78
+ - Kernel escape defeats the sandbox
79
+ - The audit database is still writable from inside the sandbox. Checkpoints make tampering with
80
+ *already-checkpointed* history detectable — not impossible, and the newest rows aren't covered yet
81
+ - A sandboxed client can't renew an expired login token — run it once outside the sandbox to refresh
82
+ - The sandbox can still *read* your OAuth token from the Keychain
83
+ - Tool results larger than 16 MiB are refused
84
+ - No external security review, no certifications
85
+ - Not audited by anyone but me — read the source, that's why it's MIT
86
+
87
+ Full threat model: THREAT-MODEL.md
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,71 @@
1
+ # Aegis
2
+
3
+ **Claude Code's sandbox lets an agent read your SSH keys and AWS credentials by default. Aegis doesn't.**
4
+
5
+ Two layers, both verified on real hardware.
6
+
7
+ Kernel sandbox — the agent's own shell cannot reach a denied path:
8
+
9
+ $ ! cat ~/.ssh/id_rsa
10
+ cat: /Users/you/.ssh/id_rsa: Operation not permitted
11
+
12
+ $ ! cat ~/.aws/credentials
13
+ cat: /Users/you/.aws/credentials: Operation not permitted
14
+
15
+ $ tail ~/Library/Application\ Support/Aegis/denials.log
16
+ kernel denied file-read-data /Users/you/.ssh/id_rsa to cat(pid 41560)
17
+ kernel denied file-read-data /Users/you/.aws/credentials to cat(pid 42180)
18
+
19
+ MCP proxy — same tool, same file, with and without Aegis in front:
20
+
21
+ direct to the server: allowed: TOKEN=proof-env-secret
22
+ through aegis proxy: AEGIS DENIED: read_text_file
23
+ Reason: path matches deny rule '.env'
24
+ Rule: deny_paths
25
+
26
+ ## What it does
27
+
28
+ Sits between your AI coding agent and your machine:
29
+
30
+ - **Deny by default** on every tool call
31
+ - **Kernel sandbox** on subprocesses — `cat .env` can't bypass it
32
+ - **Tamper-evident audit log** — hash-chained, integrity checked by `aegis doctor`, and checkpointed
33
+ from outside the sandbox under a key the sandbox can't read or write
34
+ - **Outbound requests checked** before they're made
35
+ - **Secrets never reach the MCP server**
36
+
37
+ ## Install (macOS Apple Silicon)
38
+
39
+ New here? [docs/getting-started.md](docs/getting-started.md) is the step-by-step path, including the two questions that default to No.
40
+
41
+ python3 -m pip install aegis-sandbox
42
+ aegis init # detects Claude Code / Cursor, asks a few questions
43
+ aegis doctor # proves the boundary is actually in place
44
+
45
+ **Upgrading?** Re-run `aegis init`. Your `policy.json` is yours and is never rewritten behind your back, so a
46
+ new sandbox domain does not appear on its own — and without the OAuth token endpoint a sandboxed client stops
47
+ working when its token expires. `aegis init` offers the new hosts; accepting is one keystroke.
48
+
49
+ Prefer an app? [Download the .dmg](https://github.com/Adarsh14734/Aegis/releases/download/v0.6.0/Aegis_0.6.0_aarch64.dmg)
50
+
51
+ `SHA256: bcccaa957fd3a0a15413eb1207a012f0328e309d078e7b7f2af853915e64c6dc`
52
+
53
+ Unsigned build — right-click the app → Open the first time (macOS will warn about an unidentified developer, that's expected). Or build from source.
54
+
55
+ ## What it does NOT do
56
+
57
+ - Does not stop prompt injection
58
+ - Kernel escape defeats the sandbox
59
+ - The audit database is still writable from inside the sandbox. Checkpoints make tampering with
60
+ *already-checkpointed* history detectable — not impossible, and the newest rows aren't covered yet
61
+ - A sandboxed client can't renew an expired login token — run it once outside the sandbox to refresh
62
+ - The sandbox can still *read* your OAuth token from the Keychain
63
+ - Tool results larger than 16 MiB are refused
64
+ - No external security review, no certifications
65
+ - Not audited by anyone but me — read the source, that's why it's MIT
66
+
67
+ Full threat model: THREAT-MODEL.md
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,53 @@
1
+ """Aegis — an MCP-layer policy proxy with a tamper-evident audit trail.
2
+
3
+ Read THREAT-MODEL.md before believing anything this package claims. §7 in
4
+ particular lists what Aegis does not protect against; `aegis doctor` prints an
5
+ abbreviated copy of it because that is where a new user will actually read it.
6
+
7
+ Nothing is imported here. `aegis.policy` pulls in the whole decision path and
8
+ `aegis.proxy` pulls in asyncio; a CLI that only wants `aegis.clients` should
9
+ not pay for either.
10
+
11
+ ONE side effect, added deliberately: the interpreter version is checked. It is
12
+ here because it is the only code in the package guaranteed to run before any
13
+ other — every `aegis.*` import goes through this file — and because the
14
+ alternative was what a user actually saw:
15
+
16
+ File ".../aegis/cli.py", line 401, in <module>
17
+ def main(argv: list[str] | None = None) -> int:
18
+ TypeError: unsupported operand type(s) for |: 'types.GenericAlias' and
19
+ 'NoneType'
20
+
21
+ `list[str] | None` is valid syntax on 3.9 and a TypeError the instant the `def`
22
+ is evaluated, so an old interpreter fails at import with a traceback about an
23
+ annotation. That traceback tells a person nothing they can act on, and it
24
+ arrives on screens whose whole job is to be readable. requires-python in
25
+ pyproject.toml has said >=3.10 since the beginning; this makes the package say
26
+ it in a sentence when something runs it anyway.
27
+
28
+ Written without f-strings or annotations on purpose: it has to run on the
29
+ interpreters it is refusing.
30
+ """
31
+
32
+ import sys
33
+
34
+ __version__ = "0.8.0"
35
+
36
+ # Kept in agreement, by hand, with `requires-python` in pyproject.toml and with
37
+ # MIN_PYTHON in aegis/verify.py — which cannot import this, by design (S0 open
38
+ # question #4), and with MIN_PYTHON in ui/src-tauri/src/python.rs, which is a
39
+ # different language. tests/bundle.py asserts all four agree.
40
+ MIN_PYTHON = (3, 10)
41
+
42
+ if sys.version_info < MIN_PYTHON:
43
+ _need = ".".join(str(n) for n in MIN_PYTHON)
44
+ _have = ".".join(str(n) for n in sys.version_info[:3])
45
+ sys.stderr.write(
46
+ "Aegis needs Python %s or newer, and this is Python %s.\n"
47
+ " interpreter: %s\n"
48
+ "Nothing was read and nothing was changed.\n"
49
+ "Install Python %s or newer and run Aegis with it. If you have one\n"
50
+ "already, set AEGIS_PYTHON to its full path.\n"
51
+ % (_need, _have, sys.executable, _need)
52
+ )
53
+ raise SystemExit(2)
@@ -0,0 +1,219 @@
1
+ """Aegis S5 human approval — control C7.
2
+
3
+ ASK stops collapsing to DENY. The proxy blocks the call and asks a human.
4
+
5
+ **The prompt goes to the controlling terminal, /dev/tty — never to stdin or
6
+ stdout.** stdout is the JSON-RPC channel to the client; a single stray byte
7
+ there corrupts the protocol. stdin carries client frames. Both are already
8
+ spoken for, and neither is a human. /dev/tty is the process's controlling
9
+ terminal regardless of how stdin and stdout have been redirected, which is
10
+ exactly the property needed here.
11
+
12
+ Every path that is not an explicit human "yes" is a denial:
13
+
14
+ - no controlling terminal -> DENY, rule_id ask_no_tty
15
+ - timeout (default 120s) -> DENY, rule_id approval_timeout
16
+ - EOF, empty line, anything not affirmative -> DENY, rule_id approval_denied
17
+ - any error reading or writing the terminal -> DENY, rule_id ask_no_tty
18
+
19
+ There is deliberately no configuration that turns an unanswered prompt into an
20
+ approval. T5 in THREAT-MODEL.md is approval fatigue: a control that a tired
21
+ human bypasses is not a control, and a control that approves *itself* when
22
+ nobody answers is worse than not having asked.
23
+ """
24
+
25
+ import getpass
26
+ import os
27
+ import select
28
+ import socket
29
+ import sys
30
+ import time
31
+ from dataclasses import dataclass
32
+
33
+ TTY_PATH = "/dev/tty"
34
+ DEFAULT_TIMEOUT_SECONDS = 120.0
35
+
36
+ AFFIRMATIVE = {"y", "yes"}
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Resolution:
41
+ approved: bool
42
+ rule_id: str
43
+ resolver: str
44
+ detail: str
45
+
46
+
47
+ def _who(tty_name: str) -> str:
48
+ """Who answered. Best effort — this is provenance for the audit trail, not
49
+ authentication. Anyone who can write to the terminal can answer the
50
+ prompt, and Aegis cannot tell one such person from another."""
51
+ try:
52
+ user = getpass.getuser()
53
+ except Exception: # noqa: BLE001 - no account name is not worth failing over
54
+ user = "?"
55
+ try:
56
+ host = socket.gethostname()
57
+ except Exception: # noqa: BLE001
58
+ host = "?"
59
+ return f"{user}@{host} via {tty_name}"
60
+
61
+
62
+ def describe(tool: str, rule_id: str, reason: str, paths) -> str:
63
+ """The prompt a human actually reads. Plain English, no jargon, and the
64
+ consequence of doing nothing stated explicitly."""
65
+ lines = [
66
+ "",
67
+ "=" * 66,
68
+ " AEGIS — approval required",
69
+ "=" * 66,
70
+ f" Tool: {tool}",
71
+ ]
72
+ paths = list(paths)
73
+ if paths:
74
+ lines.append(f" Files: {paths[0]}")
75
+ for p in paths[1:6]:
76
+ lines.append(f" {p}")
77
+ if len(paths) > 6:
78
+ lines.append(f" ...and {len(paths) - 6} more ({len(paths)} total)")
79
+ else:
80
+ lines.append(" Files: (none named in this call)")
81
+ lines += [
82
+ f" Why: {reason}",
83
+ f" Rule: {rule_id}",
84
+ "",
85
+ " Approving lets this call through to the server. Denying stops it.",
86
+ ]
87
+ return "\n".join(lines) + "\n"
88
+
89
+
90
+ def prompt_on(stream, tool: str, rule_id: str, reason: str, paths,
91
+ timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Resolution:
92
+ """Ask on an already-open terminal stream. Split out from resolve() so the
93
+ prompt, the timeout and the parsing can be tested against a real pty
94
+ without needing a controlling terminal."""
95
+ tty_name = getattr(stream, "name", TTY_PATH)
96
+ try:
97
+ tty_name = os.ttyname(stream.fileno())
98
+ except (OSError, ValueError, AttributeError):
99
+ pass
100
+
101
+ try:
102
+ stream.write(describe(tool, rule_id, reason, paths).encode("utf-8"))
103
+ stream.write(
104
+ f" Denied automatically in {int(timeout)}s if nobody answers.\n"
105
+ f" Approve? [y/N] ".encode("utf-8")
106
+ )
107
+ except (OSError, ValueError) as exc:
108
+ return Resolution(False, "ask_no_tty", "none",
109
+ f"could not write the prompt to {tty_name}: {exc}")
110
+
111
+ deadline = time.monotonic() + timeout
112
+ while True:
113
+ remaining = deadline - time.monotonic()
114
+ if remaining <= 0:
115
+ _say(stream, "\n TIMED OUT — denied.\n")
116
+ return Resolution(False, "approval_timeout", "timeout",
117
+ f"nobody answered within {int(timeout)}s")
118
+ try:
119
+ readable, _, _ = select.select([stream], [], [], min(remaining, 1.0))
120
+ except (OSError, ValueError) as exc:
121
+ return Resolution(False, "ask_no_tty", "none",
122
+ f"terminal became unreadable: {exc}")
123
+ if not readable:
124
+ continue
125
+
126
+ try:
127
+ line = stream.readline()
128
+ except (OSError, ValueError) as exc:
129
+ return Resolution(False, "ask_no_tty", "none",
130
+ f"could not read the answer: {exc}")
131
+
132
+ if not line: # EOF — the terminal went away
133
+ return Resolution(False, "approval_denied", "eof",
134
+ "the terminal closed without an answer")
135
+
136
+ answer = line.decode("utf-8", errors="replace").strip().lower()
137
+ who = _who(tty_name)
138
+ if answer in AFFIRMATIVE:
139
+ _say(stream, " APPROVED.\n")
140
+ return Resolution(True, "approval_granted", who, "answered yes")
141
+ _say(stream, " DENIED.\n")
142
+ return Resolution(
143
+ False, "approval_denied", who,
144
+ f"answered {answer!r}" if answer else "answered with an empty line",
145
+ )
146
+
147
+
148
+ def _say(stream, text: str) -> None:
149
+ try:
150
+ stream.write(text.encode("utf-8"))
151
+ except (OSError, ValueError):
152
+ pass
153
+
154
+
155
+ def controlling_tty_available(tty_path: str = TTY_PATH) -> tuple[bool, str]:
156
+ """Is there a controlling terminal to prompt on? (available, why not).
157
+
158
+ Checked *before* anything is written, so the headless case is answered
159
+ immediately instead of after the timeout. O_NOCTTY because probing for a
160
+ terminal must never have the side effect of acquiring one.
161
+ """
162
+ try:
163
+ fd = os.open(tty_path, os.O_RDWR | os.O_NOCTTY)
164
+ except OSError as exc:
165
+ return False, f"{tty_path}: {exc.strerror}"
166
+ try:
167
+ if not os.isatty(fd):
168
+ return False, f"{tty_path} is not a terminal"
169
+ finally:
170
+ os.close(fd)
171
+ return True, ""
172
+
173
+
174
+ def resolve(tool: str, rule_id: str, reason: str, paths,
175
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
176
+ tty_path: str = TTY_PATH) -> Resolution:
177
+ """Open the controlling terminal and ask. Blocking; the proxy runs this in
178
+ an executor so the server->client pump keeps flowing while a human thinks.
179
+
180
+ No controlling terminal means DENY, **immediately**. That is the headless
181
+ case — a proxy launched by a GUI client, a daemon, CI — and it must never
182
+ silently become an approval.
183
+
184
+ The absence is detected before prompting rather than discovered by nobody
185
+ answering. Two reasons, and the second is the one that matters:
186
+
187
+ - waiting out a 120s timeout on every ASK makes headless operation look
188
+ like a hang, once per call;
189
+ - "nobody was present to ask" and "a human was asked and did not answer"
190
+ are different events. Recording both as approval_timeout would put a
191
+ claim in the audit trail — that a person saw this and let it lapse —
192
+ which is not true. They get different rule_ids because they are
193
+ different facts.
194
+ """
195
+ available, why = controlling_tty_available(tty_path)
196
+ if not available:
197
+ return Resolution(
198
+ False, "ask_no_tty", "none",
199
+ f"no controlling terminal ({why}), so no human could be asked; "
200
+ f"denied immediately without waiting {int(timeout)}s, and recorded "
201
+ f"as nobody-present rather than as an unanswered prompt",
202
+ )
203
+
204
+ try:
205
+ stream = open(tty_path, "r+b", buffering=0)
206
+ except OSError as exc:
207
+ # Raced between the probe and the open — still nobody to ask.
208
+ return Resolution(
209
+ False, "ask_no_tty", "none",
210
+ f"controlling terminal disappeared ({tty_path}: {exc.strerror}); "
211
+ f"denying rather than assuming consent",
212
+ )
213
+ try:
214
+ return prompt_on(stream, tool, rule_id, reason, paths, timeout)
215
+ finally:
216
+ try:
217
+ stream.close()
218
+ except OSError:
219
+ pass