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.
- aegis_sandbox-0.8.0/LICENSE +21 -0
- aegis_sandbox-0.8.0/PKG-INFO +91 -0
- aegis_sandbox-0.8.0/README.md +71 -0
- aegis_sandbox-0.8.0/aegis/__init__.py +53 -0
- aegis_sandbox-0.8.0/aegis/approval.py +219 -0
- aegis_sandbox-0.8.0/aegis/audit.py +495 -0
- aegis_sandbox-0.8.0/aegis/broker.py +454 -0
- aegis_sandbox-0.8.0/aegis/checkpoint.py +180 -0
- aegis_sandbox-0.8.0/aegis/cli.py +628 -0
- aegis_sandbox-0.8.0/aegis/clients.py +472 -0
- aegis_sandbox-0.8.0/aegis/dlp.py +93 -0
- aegis_sandbox-0.8.0/aegis/doctor.py +1512 -0
- aegis_sandbox-0.8.0/aegis/egress.py +284 -0
- aegis_sandbox-0.8.0/aegis/fetch.py +641 -0
- aegis_sandbox-0.8.0/aegis/killswitch.py +136 -0
- aegis_sandbox-0.8.0/aegis/launcher.py +559 -0
- aegis_sandbox-0.8.0/aegis/onboard.py +857 -0
- aegis_sandbox-0.8.0/aegis/policy.py +832 -0
- aegis_sandbox-0.8.0/aegis/policy.template.json +32 -0
- aegis_sandbox-0.8.0/aegis/policyedit.py +634 -0
- aegis_sandbox-0.8.0/aegis/proxy.py +787 -0
- aegis_sandbox-0.8.0/aegis/sandbox.py +602 -0
- aegis_sandbox-0.8.0/aegis/trash.py +461 -0
- aegis_sandbox-0.8.0/aegis/verify.py +695 -0
- aegis_sandbox-0.8.0/aegis/violations.py +657 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/PKG-INFO +91 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/SOURCES.txt +31 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/dependency_links.txt +1 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/entry_points.txt +6 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/requires.txt +3 -0
- aegis_sandbox-0.8.0/aegis_sandbox.egg-info/top_level.txt +1 -0
- aegis_sandbox-0.8.0/pyproject.toml +58 -0
- 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
|