@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.
Files changed (40) hide show
  1. package/LICENSE.md +11 -6
  2. package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  3. package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  4. package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
  5. package/README.md +194 -212
  6. package/THIRD-PARTY.md +17 -0
  7. package/index.d.ts +6 -1
  8. package/index.js +8 -1
  9. package/package.json +17 -3
  10. package/source/.ci/wasm-bundle/Cargo.toml +1 -1
  11. package/source/CHANGELOG.md +59 -2
  12. package/source/Cargo.lock +9 -1
  13. package/source/Cargo.toml +4 -4
  14. package/source/LICENSE.md +11 -6
  15. package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  16. package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  17. package/source/README.md +67 -12
  18. package/source/THIRD-PARTY.md +17 -0
  19. package/source/dependencies.tar.gz +0 -0
  20. package/source/src/auth.rs +435 -0
  21. package/source/src/configuration.rs +51 -0
  22. package/source/src/conversation.rs +25 -0
  23. package/source/src/dock.rs +564 -0
  24. package/source/src/lib.rs +9 -0
  25. package/source/src/native/client.rs +6 -0
  26. package/source/src/native/drivers/codex.rs +506 -0
  27. package/source/src/native/drivers/mod.rs +305 -0
  28. package/source/src/native/drivers/opencode.rs +531 -0
  29. package/source/src/native/env.rs +264 -0
  30. package/source/src/native/fixture.py +78 -1
  31. package/source/src/native/mod.rs +5 -0
  32. package/source/src/native/pool.rs +440 -0
  33. package/source/src/native/session.rs +6 -0
  34. package/source/src/native/tests.rs +204 -0
  35. package/source/src/transport.rs +330 -0
  36. package/src/auth.ts +149 -0
  37. package/src/components/AccountConnection.svelte +201 -0
  38. package/src/components/Dock.svelte +172 -0
  39. package/src/dock.ts +244 -0
  40. 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":
@@ -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;