@corbet-labs/ccht 0.2.0 → 0.2.3
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.
- package/LICENSE.md +11 -6
- package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
- package/README.md +194 -212
- package/THIRD-PARTY.md +17 -0
- package/index.d.ts +6 -1
- package/index.js +8 -1
- package/package.json +17 -3
- package/source/.ci/wasm-bundle/Cargo.toml +1 -1
- package/source/CHANGELOG.md +59 -2
- package/source/Cargo.lock +9 -1
- package/source/Cargo.toml +4 -4
- package/source/LICENSE.md +11 -6
- package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/source/README.md +67 -12
- package/source/THIRD-PARTY.md +17 -0
- package/source/dependencies.tar.gz +0 -0
- package/source/src/auth.rs +435 -0
- package/source/src/configuration.rs +51 -0
- package/source/src/conversation.rs +25 -0
- package/source/src/dock.rs +564 -0
- package/source/src/lib.rs +9 -0
- package/source/src/native/client.rs +6 -0
- package/source/src/native/drivers/codex.rs +506 -0
- package/source/src/native/drivers/mod.rs +305 -0
- package/source/src/native/drivers/opencode.rs +531 -0
- package/source/src/native/env.rs +264 -0
- package/source/src/native/fixture.py +78 -1
- package/source/src/native/mod.rs +5 -0
- package/source/src/native/pool.rs +440 -0
- package/source/src/native/session.rs +6 -0
- package/source/src/native/tests.rs +204 -0
- package/source/src/transport.rs +330 -0
- package/src/auth.ts +149 -0
- package/src/components/AccountConnection.svelte +201 -0
- package/src/components/Dock.svelte +172 -0
- package/src/dock.ts +244 -0
- package/wasm/ccht_bg.wasm +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
//! Environment allowlist profile for spawning agent processes.
|
|
2
|
+
//!
|
|
3
|
+
//! Child processes inherit only explicitly allowed variables; every other
|
|
4
|
+
//! value is blanked to an empty string so secrets from the parent process
|
|
5
|
+
//! cannot silently replace native agent login. Paths and locale names are
|
|
6
|
+
//! not secrets, while tokens and keys must never be forwarded.
|
|
7
|
+
|
|
8
|
+
use std::collections::BTreeMap;
|
|
9
|
+
|
|
10
|
+
/// Environment allowlist applied to agent child processes.
|
|
11
|
+
///
|
|
12
|
+
/// [`EnvProfile::Strict`] forwards a minimal locale, home, and certificate
|
|
13
|
+
/// set. [`EnvProfile::Permissive`] is a documented superset that additionally
|
|
14
|
+
/// forwards common locale and certificate-bundle variables; both profiles
|
|
15
|
+
/// blank every other variable to an empty string.
|
|
16
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
17
|
+
pub enum EnvProfile {
|
|
18
|
+
/// Minimal allowlist for spawned agent processes.
|
|
19
|
+
Strict,
|
|
20
|
+
/// Strict set plus extra locale and certificate-bundle variables.
|
|
21
|
+
Permissive,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl EnvProfile {
|
|
25
|
+
/// Minimal profile: `PATH`, `HOME`, `XDG_*`, `LANG`/`LC_*`, `TZ`,
|
|
26
|
+
/// `TERM`, `TMPDIR`, `SSL_CERT_*`, and `NODE_EXTRA_CA_CERTS`.
|
|
27
|
+
#[must_use]
|
|
28
|
+
pub fn strict() -> Self {
|
|
29
|
+
Self::Strict
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Superset of [`EnvProfile::strict`] that additionally allows
|
|
33
|
+
/// `LANGUAGE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `CA_BUNDLE`.
|
|
34
|
+
///
|
|
35
|
+
/// These extras are `TMPDIR`-independent locale and certificate path
|
|
36
|
+
/// variables; no secret-bearing variable is added.
|
|
37
|
+
#[must_use]
|
|
38
|
+
pub fn permissive() -> Self {
|
|
39
|
+
Self::Permissive
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Whether this profile forwards `key` with its value intact.
|
|
43
|
+
fn allows(&self, key: &str) -> bool {
|
|
44
|
+
if is_strict_allowed(key) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
match self {
|
|
48
|
+
Self::Strict => false,
|
|
49
|
+
Self::Permissive => is_permissive_extra(key),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Copy `env`, keeping allowed values and blanking the rest to empty.
|
|
54
|
+
///
|
|
55
|
+
/// All input keys are preserved; disallowed values become `""` so the
|
|
56
|
+
/// child cannot inherit ambient secrets through the environment.
|
|
57
|
+
#[must_use]
|
|
58
|
+
pub fn apply(&self, env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
|
|
59
|
+
env.iter()
|
|
60
|
+
.map(|(key, value)| {
|
|
61
|
+
if self.allows(key) {
|
|
62
|
+
(key.clone(), value.clone())
|
|
63
|
+
} else {
|
|
64
|
+
(key.clone(), String::new())
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.collect()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Filter [`crate::native::AgentCommand`] environment in place.
|
|
71
|
+
///
|
|
72
|
+
/// Allowed entries keep their values; every other entry is blanked to
|
|
73
|
+
/// an empty string. The program and arguments are left unchanged.
|
|
74
|
+
pub fn apply_to_command(&self, cmd: &mut crate::native::AgentCommand) {
|
|
75
|
+
cmd.env = self.apply(&cmd.env);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Strict allowlist: exact names plus `XDG_`, `LC_`, and `SSL_CERT_` prefixes.
|
|
80
|
+
fn is_strict_allowed(key: &str) -> bool {
|
|
81
|
+
match key {
|
|
82
|
+
"PATH" | "HOME" | "LANG" | "TZ" | "TERM" | "TMPDIR" | "NODE_EXTRA_CA_CERTS" => true,
|
|
83
|
+
_ => key.starts_with("XDG_") || key.starts_with("LC_") || key.starts_with("SSL_CERT_"),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Permissive extras beyond the strict set; still locale/cert paths only.
|
|
88
|
+
fn is_permissive_extra(key: &str) -> bool {
|
|
89
|
+
matches!(
|
|
90
|
+
key,
|
|
91
|
+
"LANGUAGE" | "REQUESTS_CA_BUNDLE" | "CURL_CA_BUNDLE" | "CA_BUNDLE"
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[cfg(test)]
|
|
96
|
+
mod tests {
|
|
97
|
+
use super::*;
|
|
98
|
+
use crate::native::AgentCommand;
|
|
99
|
+
|
|
100
|
+
fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
|
101
|
+
pairs
|
|
102
|
+
.iter()
|
|
103
|
+
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
|
104
|
+
.collect()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[test]
|
|
108
|
+
fn strict_keeps_allowlist_and_blanks_secrets() {
|
|
109
|
+
let profile = EnvProfile::strict();
|
|
110
|
+
let input = env(&[
|
|
111
|
+
("PATH", "/usr/bin"),
|
|
112
|
+
("HOME", "/home/app"),
|
|
113
|
+
("XDG_CONFIG_HOME", "/home/app/.config"),
|
|
114
|
+
("LANG", "en_US.UTF-8"),
|
|
115
|
+
("LC_MESSAGES", "en_US.UTF-8"),
|
|
116
|
+
("TZ", "UTC"),
|
|
117
|
+
("TERM", "xterm"),
|
|
118
|
+
("TMPDIR", "/tmp"),
|
|
119
|
+
("SSL_CERT_FILE", "/etc/ssl/certs.pem"),
|
|
120
|
+
("NODE_EXTRA_CA_CERTS", "/etc/ssl/certs.pem"),
|
|
121
|
+
("OPENAI_API_KEY", "super-secret"),
|
|
122
|
+
("AWS_SECRET_ACCESS_KEY", "super-secret"),
|
|
123
|
+
("GITHUB_TOKEN", "super-secret"),
|
|
124
|
+
]);
|
|
125
|
+
let filtered = profile.apply(&input);
|
|
126
|
+
for (key, value) in &input {
|
|
127
|
+
if is_strict_allowed(key) {
|
|
128
|
+
assert_eq!(filtered.get(key).map(String::as_str), Some(value.as_str()));
|
|
129
|
+
} else {
|
|
130
|
+
assert_eq!(
|
|
131
|
+
filtered.get(key).map(String::as_str),
|
|
132
|
+
Some(""),
|
|
133
|
+
"expected blanking for {key}"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
assert_eq!(
|
|
138
|
+
filtered.get("OPENAI_API_KEY").map(String::as_str),
|
|
139
|
+
Some(""),
|
|
140
|
+
"secret variables must be blanked"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn permissive_is_a_documented_superset_of_strict() {
|
|
146
|
+
let strict = EnvProfile::strict();
|
|
147
|
+
let permissive = EnvProfile::permissive();
|
|
148
|
+
let strict_keys = [
|
|
149
|
+
"PATH",
|
|
150
|
+
"HOME",
|
|
151
|
+
"XDG_DATA_HOME",
|
|
152
|
+
"LANG",
|
|
153
|
+
"LC_ALL",
|
|
154
|
+
"TZ",
|
|
155
|
+
"TERM",
|
|
156
|
+
"TMPDIR",
|
|
157
|
+
"SSL_CERT_DIR",
|
|
158
|
+
"NODE_EXTRA_CA_CERTS",
|
|
159
|
+
];
|
|
160
|
+
let extra_keys = [
|
|
161
|
+
"LANGUAGE",
|
|
162
|
+
"REQUESTS_CA_BUNDLE",
|
|
163
|
+
"CURL_CA_BUNDLE",
|
|
164
|
+
"CA_BUNDLE",
|
|
165
|
+
];
|
|
166
|
+
for key in strict_keys {
|
|
167
|
+
let input = env(&[(key, "value")]);
|
|
168
|
+
assert_eq!(
|
|
169
|
+
strict.apply(&input).get(key).map(String::as_str),
|
|
170
|
+
Some("value")
|
|
171
|
+
);
|
|
172
|
+
assert_eq!(
|
|
173
|
+
permissive.apply(&input).get(key).map(String::as_str),
|
|
174
|
+
Some("value"),
|
|
175
|
+
"permissive must keep every strict variable"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
for key in extra_keys {
|
|
179
|
+
let input = env(&[(key, "value")]);
|
|
180
|
+
assert_eq!(
|
|
181
|
+
strict.apply(&input).get(key).map(String::as_str),
|
|
182
|
+
Some(""),
|
|
183
|
+
"extra variable must not be in the strict set"
|
|
184
|
+
);
|
|
185
|
+
assert_eq!(
|
|
186
|
+
permissive.apply(&input).get(key).map(String::as_str),
|
|
187
|
+
Some("value")
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn permissive_still_blanks_secrets() {
|
|
194
|
+
let filtered = EnvProfile::permissive().apply(&env(&[
|
|
195
|
+
("OPENAI_API_KEY", "super-secret"),
|
|
196
|
+
("AWS_SESSION_TOKEN", "super-secret"),
|
|
197
|
+
("ANTHROPIC_AUTH_TOKEN", "super-secret"),
|
|
198
|
+
("LANGUAGE", "en"),
|
|
199
|
+
]));
|
|
200
|
+
assert_eq!(filtered.get("OPENAI_API_KEY").map(String::as_str), Some(""));
|
|
201
|
+
assert_eq!(
|
|
202
|
+
filtered.get("AWS_SESSION_TOKEN").map(String::as_str),
|
|
203
|
+
Some("")
|
|
204
|
+
);
|
|
205
|
+
assert_eq!(
|
|
206
|
+
filtered.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
|
|
207
|
+
Some("")
|
|
208
|
+
);
|
|
209
|
+
assert_eq!(filtered.get("LANGUAGE").map(String::as_str), Some("en"));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn apply_to_command_filters_env_without_touching_program() {
|
|
214
|
+
let mut command = AgentCommand::new("agent");
|
|
215
|
+
command.env.insert("PATH".into(), "/usr/bin".into());
|
|
216
|
+
command
|
|
217
|
+
.env
|
|
218
|
+
.insert("OPENAI_API_KEY".into(), "super-secret".into());
|
|
219
|
+
EnvProfile::strict().apply_to_command(&mut command);
|
|
220
|
+
assert_eq!(
|
|
221
|
+
command.program.to_str(),
|
|
222
|
+
Some("agent"),
|
|
223
|
+
"program must be unchanged"
|
|
224
|
+
);
|
|
225
|
+
assert_eq!(
|
|
226
|
+
command.env.get("PATH").map(String::as_str),
|
|
227
|
+
Some("/usr/bin")
|
|
228
|
+
);
|
|
229
|
+
assert_eq!(
|
|
230
|
+
command.env.get("OPENAI_API_KEY").map(String::as_str),
|
|
231
|
+
Some("")
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#[test]
|
|
236
|
+
fn agent_command_debug_shows_keys_only() {
|
|
237
|
+
let mut command = AgentCommand::new("agent");
|
|
238
|
+
command
|
|
239
|
+
.env
|
|
240
|
+
.insert("OPENAI_API_KEY".into(), "super-secret-value".into());
|
|
241
|
+
EnvProfile::strict().apply_to_command(&mut command);
|
|
242
|
+
let rendered = format!("{command:?}");
|
|
243
|
+
assert!(
|
|
244
|
+
rendered.contains("OPENAI_API_KEY"),
|
|
245
|
+
"debug output should keep environment keys"
|
|
246
|
+
);
|
|
247
|
+
assert!(
|
|
248
|
+
!rendered.contains("super-secret-value"),
|
|
249
|
+
"debug output must never leak environment values"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn empty_environment_stays_empty_and_names_are_case_sensitive() {
|
|
255
|
+
let empty: BTreeMap<String, String> = BTreeMap::new();
|
|
256
|
+
assert!(EnvProfile::strict().apply(&empty).is_empty());
|
|
257
|
+
assert!(EnvProfile::permissive().apply(&empty).is_empty());
|
|
258
|
+
// Allowlist names are exact; lowercase variants must be blanked.
|
|
259
|
+
let input = env(&[("path", "/usr/bin"), ("Path", "/usr/bin")]);
|
|
260
|
+
let filtered = EnvProfile::strict().apply(&input);
|
|
261
|
+
assert_eq!(filtered.get("path").map(String::as_str), Some(""));
|
|
262
|
+
assert_eq!(filtered.get("Path").map(String::as_str), Some(""));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -12,7 +12,7 @@ configurations = {}
|
|
|
12
12
|
pending = {}
|
|
13
13
|
permissions = {}
|
|
14
14
|
permission_id = 100
|
|
15
|
-
if len(sys.argv) > 2:
|
|
15
|
+
if len(sys.argv) > 2 and sys.argv[2].endswith(".json"):
|
|
16
16
|
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
|
|
17
17
|
pathlib.Path(sys.argv[2]).write_text(json.dumps({"agent": os.getpid(), "descendant": child.pid}))
|
|
18
18
|
|
|
@@ -31,6 +31,61 @@ def update(session, text):
|
|
|
31
31
|
"content": {"type": "text", "text": text}}}})
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
# Fake OpenCode control server for driver tests. The Rust driver spawns:
|
|
35
|
+
# python3 -u -c <this file> <opencode_ok|opencode_fail|opencode_never>
|
|
36
|
+
# serve --hostname 127.0.0.1 --port <port>
|
|
37
|
+
# Only loopback is used; no real network leaves the host.
|
|
38
|
+
if "serve" in sys.argv:
|
|
39
|
+
if mode == "opencode_never":
|
|
40
|
+
time.sleep(60)
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
port = 0
|
|
43
|
+
if "--port" in sys.argv:
|
|
44
|
+
port = int(sys.argv[sys.argv.index("--port") + 1])
|
|
45
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
46
|
+
|
|
47
|
+
class DriverHandler(BaseHTTPRequestHandler):
|
|
48
|
+
def log_message(self, format, *args):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def _reply(self, code, body):
|
|
52
|
+
data = body if isinstance(body, bytes) else body.encode()
|
|
53
|
+
self.send_response(code)
|
|
54
|
+
self.send_header("Content-Type", "application/json")
|
|
55
|
+
self.send_header("Content-Length", str(len(data)))
|
|
56
|
+
self.send_header("Connection", "close")
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(data)
|
|
59
|
+
|
|
60
|
+
def do_GET(self):
|
|
61
|
+
path = self.path.split("?")[0]
|
|
62
|
+
if path == "/global/health":
|
|
63
|
+
self._reply(200, b"{}")
|
|
64
|
+
elif path == "/provider":
|
|
65
|
+
if mode == "opencode_fail":
|
|
66
|
+
self._reply(200, json.dumps({"connected": []}).encode())
|
|
67
|
+
else:
|
|
68
|
+
self._reply(200, json.dumps({"connected": ["opencode-go"]}).encode())
|
|
69
|
+
else:
|
|
70
|
+
self._reply(404, b"{}")
|
|
71
|
+
|
|
72
|
+
def do_PUT(self):
|
|
73
|
+
path = self.path.split("?")[0]
|
|
74
|
+
if path == "/auth/opencode-go":
|
|
75
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
76
|
+
if length > 0:
|
|
77
|
+
self.rfile.read(min(length, 1_048_576))
|
|
78
|
+
if mode == "opencode_fail":
|
|
79
|
+
self._reply(500, b"{}")
|
|
80
|
+
else:
|
|
81
|
+
self._reply(200, b"{}")
|
|
82
|
+
else:
|
|
83
|
+
self._reply(404, b"{}")
|
|
84
|
+
|
|
85
|
+
HTTPServer(("127.0.0.1", port), DriverHandler).serve_forever()
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
|
|
88
|
+
|
|
34
89
|
for line in sys.stdin:
|
|
35
90
|
message = json.loads(line)
|
|
36
91
|
method = message.get("method")
|
|
@@ -100,6 +155,28 @@ for line in sys.stdin:
|
|
|
100
155
|
response(pending.pop(session), {"stopReason": "cancelled"})
|
|
101
156
|
elif method == "session/close":
|
|
102
157
|
response(request_id, {})
|
|
158
|
+
elif method == "account/login/start":
|
|
159
|
+
if mode == "codex_bad_url":
|
|
160
|
+
response(request_id, {"loginId": "login-1",
|
|
161
|
+
"verificationUrl": "http://evil.example.com/x",
|
|
162
|
+
"userCode": "ABCD-1234"})
|
|
163
|
+
elif mode == "codex_bad_code":
|
|
164
|
+
response(request_id, {"loginId": "login-1",
|
|
165
|
+
"verificationUrl": "https://auth.openai.com/codex/device",
|
|
166
|
+
"userCode": "!!!"})
|
|
167
|
+
else:
|
|
168
|
+
response(request_id, {"loginId": "login-1",
|
|
169
|
+
"verificationUrl": "https://auth.openai.com/codex/device",
|
|
170
|
+
"userCode": "ABCD-1234"})
|
|
171
|
+
if mode == "codex_ok":
|
|
172
|
+
send({"method": "account/login/completed",
|
|
173
|
+
"params": {"loginId": "login-1", "success": True}})
|
|
174
|
+
elif mode == "codex_declined":
|
|
175
|
+
send({"method": "account/login/completed",
|
|
176
|
+
"params": {"loginId": "login-1", "success": False}})
|
|
177
|
+
elif method == "account/read":
|
|
178
|
+
response(request_id, {"account": {"type": "chatgpt",
|
|
179
|
+
"email": "user@example.com"}})
|
|
103
180
|
elif method is None and request_id in permissions:
|
|
104
181
|
session = permissions.pop(request_id)
|
|
105
182
|
if mode == "unsupported_host_request":
|
package/source/src/native/mod.rs
CHANGED
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
//! sandbox: an agent can have tools that do not ask the client for permission.
|
|
7
7
|
|
|
8
8
|
mod client;
|
|
9
|
+
pub mod drivers;
|
|
10
|
+
mod env;
|
|
11
|
+
pub mod pool;
|
|
9
12
|
mod session;
|
|
10
13
|
|
|
11
14
|
#[cfg(test)]
|
|
12
15
|
mod tests;
|
|
13
16
|
|
|
14
17
|
pub use client::NativeClient;
|
|
18
|
+
pub use env::EnvProfile;
|
|
19
|
+
pub use pool::{PoolEvent, SessionKey, SessionPool};
|
|
15
20
|
pub use session::{NativeSession, SessionHandle};
|
|
16
21
|
|
|
17
22
|
use std::collections::BTreeMap;
|