codicent-cli 0.8.5__tar.gz → 0.9.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 (27) hide show
  1. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/PKG-INFO +11 -1
  2. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/README.md +10 -0
  3. codicent_cli-0.9.0/agent.py +431 -0
  4. codicent_cli-0.9.0/agent_policy.py +78 -0
  5. codicent_cli-0.9.0/agent_protocol.py +76 -0
  6. codicent_cli-0.9.0/agent_session.py +73 -0
  7. codicent_cli-0.9.0/agent_tools.py +430 -0
  8. codicent_cli-0.9.0/agent_workspace.py +113 -0
  9. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/app.py +1462 -1412
  10. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/codicent_cli.egg-info/PKG-INFO +11 -1
  11. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/codicent_cli.egg-info/SOURCES.txt +7 -0
  12. codicent_cli-0.9.0/codicent_cli.egg-info/top_level.txt +12 -0
  13. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/setup.py +61 -57
  14. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/terminal.py +344 -252
  15. codicent_cli-0.9.0/tui.py +1169 -0
  16. codicent_cli-0.8.5/codicent_cli.egg-info/top_level.txt +0 -5
  17. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/LICENSE +0 -0
  18. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/auth.py +0 -0
  19. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/codicent_cli.egg-info/dependency_links.txt +0 -0
  20. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/codicent_cli.egg-info/entry_points.txt +0 -0
  21. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/codicent_cli.egg-info/requires.txt +0 -0
  22. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/setup.cfg +0 -0
  23. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/templates/__init__.py +0 -0
  24. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/templates/default.json +0 -0
  25. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/templates/document-processing.json +0 -0
  26. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/templates/koi-studio.json +0 -0
  27. {codicent_cli-0.8.5 → codicent_cli-0.9.0}/wrap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.5
3
+ Version: 0.9.0
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -101,6 +101,16 @@ pip install git+https://github.com/izaxon/codicent-cli.git@v0.4.8
101
101
 
102
102
  ## Usage
103
103
 
104
+ ### Coding agent sandbox
105
+
106
+ `codicent agent exec` defaults to `--sandbox workspace-write`, which confines
107
+ filesystem tools to `--cwd`. `read-only` also forbids file edits. Both modes
108
+ reject shell commands because setting a command's working directory does not
109
+ isolate its filesystem access. To allow shell commands, explicitly pass
110
+ `--sandbox danger-full-access`; processes can then access files outside the workspace.
111
+ `--approval-policy on-request` asks before mutations; `never` skips those prompts.
112
+ Search and instruction discovery exclude links that escape their workspace or repository.
113
+
104
114
  ### Basic Setup
105
115
 
106
116
  1. Set the `CODICENT_TOKEN` environment variable with your Codicent API token:
@@ -55,6 +55,16 @@ pip install git+https://github.com/izaxon/codicent-cli.git@v0.4.8
55
55
 
56
56
  ## Usage
57
57
 
58
+ ### Coding agent sandbox
59
+
60
+ `codicent agent exec` defaults to `--sandbox workspace-write`, which confines
61
+ filesystem tools to `--cwd`. `read-only` also forbids file edits. Both modes
62
+ reject shell commands because setting a command's working directory does not
63
+ isolate its filesystem access. To allow shell commands, explicitly pass
64
+ `--sandbox danger-full-access`; processes can then access files outside the workspace.
65
+ `--approval-policy on-request` asks before mutations; `never` skips those prompts.
66
+ Search and instruction discovery exclude links that escape their workspace or repository.
67
+
58
68
  ### Basic Setup
59
69
 
60
70
  1. Set the `CODICENT_TOKEN` environment variable with your Codicent API token:
@@ -0,0 +1,431 @@
1
+ """`codicent agent` — standalone coding-agent mode (ADR-023).
2
+
3
+ The Codicent server is the reasoning layer (model, memory, tool loop); this
4
+ module is the local harness: workspace boundary, sandbox/approval enforcement,
5
+ local tool execution, session persistence, and structured stdout for
6
+ orchestrators like DINT. Rich is intentionally never imported here.
7
+ """
8
+ import argparse
9
+ import base64
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import time
15
+
16
+ import requests
17
+
18
+ from agent_policy import (
19
+ AgentPolicy,
20
+ APPROVAL_NEVER,
21
+ APPROVAL_ON_REQUEST,
22
+ APPROVAL_POLICIES,
23
+ NEED_APPROVAL,
24
+ SANDBOX_FULL_ACCESS,
25
+ SANDBOX_LEVELS,
26
+ SANDBOX_WORKSPACE_WRITE,
27
+ )
28
+ from agent_protocol import (
29
+ EXIT_APPROVAL_REQUIRED,
30
+ EXIT_CANCELLED,
31
+ EXIT_ERROR,
32
+ EXIT_OK,
33
+ OUTPUT_FORMATS,
34
+ OUTPUT_JSONL,
35
+ OUTPUT_TEXT,
36
+ ProtocolEmitter,
37
+ )
38
+ from agent_session import AgentSession, SessionError
39
+ from agent_tools import STATUS_CANCELLED, STATUS_NEED_APPROVAL, ToolDispatcher
40
+ from agent_workspace import WorkspaceContext, WorkspaceError
41
+
42
+ POLL_INTERVAL_SECONDS = 1.5
43
+ DEFAULT_TURN_TIMEOUT = 600
44
+
45
+ DELEGATION_PREFIXES = (
46
+ "#execute_command\n",
47
+ "#fs_read\n",
48
+ "#fs_write\n",
49
+ "#fs_list\n",
50
+ "#fs_search\n",
51
+ "#apply_patch\n",
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Wire protocol (StartAi2ChatAsync / GetMessageHistory / AddChatMessage)
57
+ # ---------------------------------------------------------------------------
58
+ def decode_jwt_payload(token):
59
+ payload = token.split(".")[1]
60
+ payload += "=" * (-len(payload) % 4)
61
+ return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
62
+
63
+
64
+ def start_chat_async(base_url, token, project, message, conversation_id=None):
65
+ payload = {"message": message, "project": project}
66
+ if conversation_id:
67
+ payload["messageId"] = str(conversation_id)
68
+ resp = requests.post(
69
+ f"{base_url}app/StartAi2ChatAsync",
70
+ json=payload,
71
+ headers={"Authorization": f"Bearer {token}"},
72
+ timeout=30,
73
+ )
74
+ resp.raise_for_status()
75
+ return resp.json().get("promptMessageId")
76
+
77
+
78
+ def fetch_history(base_url, token, message_id):
79
+ resp = requests.get(
80
+ f"{base_url}app/GetMessageHistory",
81
+ params={"messageId": str(message_id)},
82
+ headers={"Authorization": f"Bearer {token}"},
83
+ timeout=15,
84
+ )
85
+ resp.raise_for_status()
86
+ return resp.json() if resp.text else []
87
+
88
+
89
+ def post_child_message(base_url, token, project, parent_id, body):
90
+ resp = requests.post(
91
+ f"{base_url}app/AddChatMessage",
92
+ json={"content": f"@{project}\n{body}", "type": "ai-chat-answer2", "parentId": str(parent_id)},
93
+ headers={"Authorization": f"Bearer {token}"},
94
+ timeout=30,
95
+ )
96
+ resp.raise_for_status()
97
+ return resp.json()
98
+
99
+
100
+ def post_function_result(base_url, token, project, parent_id, function_name, result_content, tool_id):
101
+ fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
102
+ return post_child_message(base_url, token, project, parent_id, f"#function_result\n{json.dumps(fr)}")
103
+
104
+
105
+ def post_batch_results(base_url, token, project, parent_id, items):
106
+ """items: original batch dicts with delegation contents replaced in place
107
+ (key casing preserved, so the server deserializes what it serialized)."""
108
+ return post_child_message(
109
+ base_url, token, project, parent_id, f"#function_results_batch\n{json.dumps(items)}"
110
+ )
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Delegation parsing
115
+ # ---------------------------------------------------------------------------
116
+ def strip_mention_and_tag(content, tag):
117
+ body = re.sub(r"^@\S+\s*", "", content).strip()
118
+ return re.sub(r"^" + re.escape(tag) + r"\s*\n?", "", body).strip()
119
+
120
+
121
+ def parse_delegation_content(content):
122
+ """If content starts with a delegation prefix, return (tool, data), else None."""
123
+ if not content:
124
+ return None
125
+ for prefix in DELEGATION_PREFIXES:
126
+ if content.startswith(prefix):
127
+ try:
128
+ data = json.loads(content[len(prefix):])
129
+ except ValueError:
130
+ return None
131
+ return prefix.rstrip("\n").lstrip("#"), data
132
+ return None
133
+
134
+
135
+ def parse_single_delegation(message_content):
136
+ """Parse a #function_result message. Returns (name, tool, data, tool_id) or None."""
137
+ body = strip_mention_and_tag(message_content, "#function_result")
138
+ try:
139
+ fr = json.loads(body)
140
+ except ValueError:
141
+ return None
142
+ parsed = parse_delegation_content(fr.get("content") or fr.get("Content") or "")
143
+ if not parsed:
144
+ return None
145
+ tool, data = parsed
146
+ return fr.get("name") or fr.get("Name") or tool, tool, data, fr.get("id") or fr.get("Id") or ""
147
+
148
+
149
+ def parse_batch_delegations(message_content):
150
+ """Parse a #function_results_batch message.
151
+
152
+ Returns (items, delegations) where items is the original list of dicts and
153
+ delegations is a list of (index, content_key, tool, data); empty
154
+ delegations means the batch is already fully resolved.
155
+ Returns None if the content is not a valid batch.
156
+ """
157
+ body = strip_mention_and_tag(message_content, "#function_results_batch")
158
+ try:
159
+ items = json.loads(body)
160
+ except ValueError:
161
+ return None
162
+ if not isinstance(items, list):
163
+ return None
164
+ delegations = []
165
+ for index, item in enumerate(items):
166
+ if not isinstance(item, dict):
167
+ continue
168
+ content_key = "content" if "content" in item else ("Content" if "Content" in item else None)
169
+ if content_key is None:
170
+ continue
171
+ parsed = parse_delegation_content(item.get(content_key) or "")
172
+ if parsed:
173
+ tool, data = parsed
174
+ delegations.append((index, content_key, tool, data))
175
+ return items, delegations
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # CLI entry
180
+ # ---------------------------------------------------------------------------
181
+ def build_parser():
182
+ parser = argparse.ArgumentParser(
183
+ prog="codicent agent",
184
+ description="Run Codicent as a repository coding agent in the current folder.",
185
+ )
186
+ sub = parser.add_subparsers(dest="subcommand")
187
+ execp = sub.add_parser("exec", help="Execute one agent turn")
188
+ execp.add_argument("--cwd", default=None, help="Workspace root (default: current directory)")
189
+ execp.add_argument("--output-format", choices=OUTPUT_FORMATS, default=OUTPUT_TEXT)
190
+ execp.add_argument("--sandbox", choices=SANDBOX_LEVELS, default=SANDBOX_WORKSPACE_WRITE,
191
+ help="Filesystem access level; shell commands require danger-full-access")
192
+ execp.add_argument("--approval-policy", choices=APPROVAL_POLICIES, default=APPROVAL_ON_REQUEST)
193
+ execp.add_argument("--resume", default=None, metavar="SESSION_ID")
194
+ execp.add_argument("--prompt", default=None, help="The task prompt (or pass as positional text)")
195
+ execp.add_argument("--timeout", type=float, default=DEFAULT_TURN_TIMEOUT, help="Turn timeout in seconds")
196
+ execp.add_argument("prompt_words", nargs="*", help=argparse.SUPPRESS)
197
+ return parser
198
+
199
+
200
+ def _get_token():
201
+ from auth import CodicentAuth
202
+
203
+ token = CodicentAuth().get_cached_token()
204
+ if not token:
205
+ token = os.getenv("CODICENT_TOKEN")
206
+ return token
207
+
208
+
209
+ def build_prompt_message(prompt, instructions):
210
+ if not instructions:
211
+ return prompt
212
+ parts = ["<repository-instructions>"]
213
+ for name, content in instructions:
214
+ parts.append(f"# file: {name}")
215
+ parts.append(content)
216
+ parts.append("</repository-instructions>")
217
+ parts.append("")
218
+ parts.append(prompt)
219
+ return "\n".join(parts)
220
+
221
+
222
+ def run_agent_command(argv):
223
+ parser = build_parser()
224
+ args = parser.parse_args(argv)
225
+ if args.subcommand != "exec":
226
+ parser.print_help(sys.stderr)
227
+ return EXIT_ERROR
228
+
229
+ emitter = ProtocolEmitter(output_format=args.output_format)
230
+
231
+ prompt = args.prompt or " ".join(args.prompt_words).strip()
232
+ if not prompt:
233
+ emitter.error("no prompt given (use --prompt)")
234
+ return EXIT_ERROR
235
+
236
+ try:
237
+ workspace = WorkspaceContext(args.cwd)
238
+ except WorkspaceError as e:
239
+ emitter.error(str(e))
240
+ return EXIT_ERROR
241
+
242
+ token = _get_token()
243
+ if not token:
244
+ emitter.error("authentication required: run 'codicent auth' or set CODICENT_TOKEN")
245
+ return EXIT_ERROR
246
+ try:
247
+ jwt = decode_jwt_payload(token)
248
+ project = jwt["project"]
249
+ base_url = jwt.get("baseUrl", "https://codicent.com/")
250
+ except Exception as e:
251
+ emitter.error(f"could not read project from token: {e}")
252
+ return EXIT_ERROR
253
+
254
+ try:
255
+ if args.resume:
256
+ session = AgentSession.load(args.resume)
257
+ else:
258
+ session = AgentSession.new(workspace.root)
259
+ session.save()
260
+ except SessionError as e:
261
+ emitter.error(str(e))
262
+ return EXIT_ERROR
263
+ emitter.session_id = session.session_id
264
+ emitter.session()
265
+
266
+ policy = AgentPolicy(sandbox=args.sandbox, approval=args.approval_policy)
267
+ headless = args.output_format == OUTPUT_JSONL or not sys.stdin.isatty()
268
+ confirm_fn = None
269
+ if policy.approval == APPROVAL_ON_REQUEST and not headless:
270
+ def confirm_fn(tool, summary):
271
+ try:
272
+ answer = input(f"Allow {tool}: {summary}? (y/N) ")
273
+ except (KeyboardInterrupt, EOFError):
274
+ return False
275
+ return answer.strip().lower() == "y"
276
+
277
+ dispatcher = ToolDispatcher(workspace, policy, confirm_fn=confirm_fn)
278
+
279
+ if args.sandbox == SANDBOX_FULL_ACCESS:
280
+ emitter.log("WARNING: danger-full-access sandbox — no workspace confinement")
281
+
282
+ instructions = []
283
+ try:
284
+ instructions = workspace.discover_instructions()
285
+ if instructions:
286
+ emitter.log(f"instructions: {', '.join(name for name, _ in instructions)}")
287
+ except Exception as e:
288
+ emitter.log(f"instruction discovery failed: {e}")
289
+
290
+ message = build_prompt_message(prompt, instructions)
291
+
292
+ try:
293
+ return _run_turn(emitter, dispatcher, session, base_url, token, project, message, args.timeout, headless)
294
+ except KeyboardInterrupt:
295
+ dispatcher.cancel()
296
+ emitter.error("cancelled")
297
+ return EXIT_CANCELLED
298
+ except requests.RequestException as e:
299
+ emitter.error(f"server request failed: {e}")
300
+ return EXIT_ERROR
301
+
302
+
303
+ def _run_turn(emitter, dispatcher, session, base_url, token, project, message, timeout, headless):
304
+ prompt_id = start_chat_async(base_url, token, project, message, session.conversation_id)
305
+ if not prompt_id:
306
+ emitter.error("server did not return a prompt message id")
307
+ return EXIT_ERROR
308
+ session.touch(prompt_id)
309
+
310
+ deadline = time.time() + timeout
311
+ last_seen_id = None
312
+
313
+ while time.time() < deadline:
314
+ if dispatcher.cancelled:
315
+ emitter.error("cancelled")
316
+ return EXIT_CANCELLED
317
+ try:
318
+ history = fetch_history(base_url, token, prompt_id)
319
+ except requests.RequestException as e:
320
+ emitter.log(f"history fetch error: {e}")
321
+ time.sleep(POLL_INTERVAL_SECONDS)
322
+ continue
323
+
324
+ if not history or len(history) < 2:
325
+ time.sleep(POLL_INTERVAL_SECONDS)
326
+ continue
327
+
328
+ last = history[-1]
329
+ last_id = last.get("id") or last.get("Id")
330
+ content = last.get("content") or last.get("Content") or ""
331
+
332
+ if last_id == last_seen_id:
333
+ time.sleep(POLL_INTERVAL_SECONDS)
334
+ continue
335
+ last_seen_id = last_id
336
+
337
+ # Batch delegation (check before single: "#function_result" is a
338
+ # substring of "#function_results_batch").
339
+ if "#function_results_batch" in content:
340
+ if not dispatcher.already_handled(last_id):
341
+ parsed = parse_batch_delegations(content)
342
+ if parsed:
343
+ items, delegations = parsed
344
+ if delegations:
345
+ code = _handle_batch(
346
+ emitter, dispatcher, base_url, token, project, last_id, items, delegations, headless
347
+ )
348
+ if code is not None:
349
+ return code
350
+ dispatcher.mark_handled(last_id)
351
+ time.sleep(POLL_INTERVAL_SECONDS)
352
+ continue
353
+
354
+ if "#function_result" in content:
355
+ if not dispatcher.already_handled(last_id):
356
+ delegation = parse_single_delegation(content)
357
+ if delegation:
358
+ name, tool, data, tool_id = delegation
359
+ code = _handle_single(
360
+ emitter, dispatcher, base_url, token, project, last_id, name, tool, data, tool_id, headless
361
+ )
362
+ if code is not None:
363
+ return code
364
+ dispatcher.mark_handled(last_id)
365
+ time.sleep(POLL_INTERVAL_SECONDS)
366
+ continue
367
+
368
+ intermediate_markers = ("#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch")
369
+ if any(marker in content for marker in intermediate_markers):
370
+ time.sleep(POLL_INTERVAL_SECONDS)
371
+ continue
372
+
373
+ # Final answer: strip @mention and leading tags for output.
374
+ clean = re.sub(r"^@\S+\s*", "", content).strip()
375
+ clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
376
+ session.touch(last_id)
377
+ emitter.text(clean)
378
+ emitter.done()
379
+ return EXIT_OK
380
+
381
+ emitter.error(f"turn timed out after {int(timeout)}s")
382
+ return EXIT_ERROR
383
+
384
+
385
+ def _handle_single(emitter, dispatcher, base_url, token, project, msg_id, name, tool, data, tool_id, headless):
386
+ """Execute one delegated call and post the result. Returns an exit code to
387
+ terminate with, or None to continue polling."""
388
+ decision = dispatcher.evaluate(tool, data)
389
+ if decision.verdict == NEED_APPROVAL and headless:
390
+ emitter.need_approval(tool, dispatcher.describe(tool, data))
391
+ return EXIT_APPROVAL_REQUIRED
392
+
393
+ emitter.tool(tool, dispatcher.describe(tool, data))
394
+ result, status = dispatcher.dispatch(tool, data)
395
+ if status == STATUS_CANCELLED:
396
+ emitter.error("cancelled")
397
+ return EXIT_CANCELLED
398
+ if status == STATUS_NEED_APPROVAL:
399
+ emitter.need_approval(tool, dispatcher.describe(tool, data))
400
+ return EXIT_APPROVAL_REQUIRED
401
+ post_function_result(base_url, token, project, msg_id, name, result, tool_id)
402
+ return None
403
+
404
+
405
+ def _handle_batch(emitter, dispatcher, base_url, token, project, msg_id, items, delegations, headless):
406
+ """Execute every delegated item in a batch and post the resolved batch.
407
+ Returns an exit code to terminate with, or None to continue polling."""
408
+ if headless:
409
+ pending = [
410
+ (tool, data)
411
+ for _index, _key, tool, data in delegations
412
+ if dispatcher.evaluate(tool, data).verdict == NEED_APPROVAL
413
+ ]
414
+ if pending:
415
+ for tool, data in pending:
416
+ emitter.need_approval(tool, dispatcher.describe(tool, data))
417
+ return EXIT_APPROVAL_REQUIRED
418
+
419
+ for index, content_key, tool, data in delegations:
420
+ emitter.tool(tool, dispatcher.describe(tool, data))
421
+ result, status = dispatcher.dispatch(tool, data)
422
+ if status == STATUS_CANCELLED:
423
+ emitter.error("cancelled")
424
+ return EXIT_CANCELLED
425
+ if status == STATUS_NEED_APPROVAL:
426
+ emitter.need_approval(tool, dispatcher.describe(tool, data))
427
+ return EXIT_APPROVAL_REQUIRED
428
+ items[index][content_key] = result
429
+
430
+ post_batch_results(base_url, token, project, msg_id, items)
431
+ return None
@@ -0,0 +1,78 @@
1
+ """Sandbox and approval policy for agent mode (ADR-023 §6–§7).
2
+
3
+ Sandbox level and approval policy are independent axes: the sandbox says what
4
+ is reachable at all, the approval policy says what needs a human first.
5
+ Unknown tools are denied — the policy is fail-closed.
6
+ """
7
+ from collections import namedtuple
8
+
9
+ from agent_workspace import WorkspaceEscapeError
10
+
11
+ SANDBOX_READ_ONLY = "read-only"
12
+ SANDBOX_WORKSPACE_WRITE = "workspace-write"
13
+ SANDBOX_FULL_ACCESS = "danger-full-access"
14
+ SANDBOX_LEVELS = (SANDBOX_READ_ONLY, SANDBOX_WORKSPACE_WRITE, SANDBOX_FULL_ACCESS)
15
+
16
+ APPROVAL_ON_REQUEST = "on-request"
17
+ APPROVAL_NEVER = "never"
18
+ APPROVAL_POLICIES = (APPROVAL_ON_REQUEST, APPROVAL_NEVER)
19
+
20
+ READ_TOOLS = frozenset({"fs_read", "fs_list", "fs_search"})
21
+ WRITE_TOOLS = frozenset({"fs_write", "apply_patch"})
22
+ COMMAND_TOOLS = frozenset({"execute_command"})
23
+ KNOWN_TOOLS = READ_TOOLS | WRITE_TOOLS | COMMAND_TOOLS
24
+
25
+ ALLOW = "allow"
26
+ DENY = "deny"
27
+ NEED_APPROVAL = "need_approval"
28
+
29
+ Decision = namedtuple("Decision", ["verdict", "reason"])
30
+
31
+
32
+ class AgentPolicy:
33
+ def __init__(self, sandbox=SANDBOX_WORKSPACE_WRITE, approval=APPROVAL_ON_REQUEST):
34
+ if sandbox not in SANDBOX_LEVELS:
35
+ raise ValueError(f"unknown sandbox level: {sandbox}")
36
+ if approval not in APPROVAL_POLICIES:
37
+ raise ValueError(f"unknown approval policy: {approval}")
38
+ self.sandbox = sandbox
39
+ self.approval = approval
40
+
41
+ def evaluate(self, tool, data, workspace):
42
+ """Return a Decision for one delegated tool call. Never raises."""
43
+ if tool not in KNOWN_TOOLS:
44
+ return Decision(DENY, f"unknown tool '{tool}'")
45
+
46
+ if tool in READ_TOOLS:
47
+ return self._check_path(data.get("path", ""), workspace)
48
+
49
+ if tool in WRITE_TOOLS:
50
+ if self.sandbox == SANDBOX_READ_ONLY:
51
+ return Decision(DENY, "read-only sandbox forbids filesystem writes")
52
+ if tool != "apply_patch":
53
+ # Paths are validated per-file when the patch is parsed; fs_write
54
+ # is checked here.
55
+ decision = self._check_path(data.get("path", ""), workspace)
56
+ if decision.verdict != ALLOW:
57
+ return decision
58
+ if self.approval == APPROVAL_ON_REQUEST:
59
+ return Decision(NEED_APPROVAL, "filesystem write requires approval")
60
+ return Decision(ALLOW, "")
61
+
62
+ # execute_command
63
+ if self.sandbox == SANDBOX_READ_ONLY:
64
+ return Decision(DENY, "read-only sandbox forbids shell commands")
65
+ if self.sandbox != SANDBOX_FULL_ACCESS:
66
+ return Decision(DENY, "shell commands require --sandbox danger-full-access; cwd confinement cannot sandbox a process")
67
+ if self.approval == APPROVAL_ON_REQUEST:
68
+ return Decision(NEED_APPROVAL, "command execution requires approval")
69
+ return Decision(ALLOW, "")
70
+
71
+ def _check_path(self, path, workspace):
72
+ if self.sandbox == SANDBOX_FULL_ACCESS:
73
+ return Decision(ALLOW, "")
74
+ try:
75
+ workspace.resolve(path)
76
+ except WorkspaceEscapeError as e:
77
+ return Decision(DENY, str(e))
78
+ return Decision(ALLOW, "")
@@ -0,0 +1,76 @@
1
+ """Structured output protocol for `codicent agent exec` (ADR-023 §3–§4, frozen).
2
+
3
+ JSONL mode writes exactly one JSON object per stdout line and nothing else;
4
+ all diagnostics go to stderr. Text mode is a minimal human-readable rendering
5
+ (never Rich — Rich stays out of agent mode entirely).
6
+ """
7
+ import json
8
+ import sys
9
+
10
+ EXIT_OK = 0
11
+ EXIT_ERROR = 1
12
+ EXIT_APPROVAL_REQUIRED = 2
13
+ EXIT_CANCELLED = 130
14
+
15
+ OUTPUT_JSONL = "jsonl"
16
+ OUTPUT_TEXT = "text"
17
+ OUTPUT_FORMATS = (OUTPUT_JSONL, OUTPUT_TEXT)
18
+
19
+ EVENT_TYPES = ("session", "tool", "need_approval", "text", "done", "error")
20
+
21
+
22
+ class ProtocolEmitter:
23
+ def __init__(self, session_id="", output_format=OUTPUT_TEXT, stdout=None, stderr=None):
24
+ if output_format not in OUTPUT_FORMATS:
25
+ raise ValueError(f"unknown output format: {output_format}")
26
+ self.session_id = session_id
27
+ self.output_format = output_format
28
+ self.stdout = stdout if stdout is not None else sys.stdout
29
+ self.stderr = stderr if stderr is not None else sys.stderr
30
+
31
+ def _emit(self, event_type, text=None, tool=None):
32
+ event = {"type": event_type, "session_id": self.session_id}
33
+ if tool is not None:
34
+ event["tool"] = tool
35
+ if text is not None:
36
+ event["text"] = text
37
+ if self.output_format == OUTPUT_JSONL:
38
+ self.stdout.write(json.dumps(event, ensure_ascii=False) + "\n")
39
+ else:
40
+ if event_type == "session":
41
+ self.stdout.write(f"[session {self.session_id}]\n")
42
+ elif event_type == "tool":
43
+ self.stdout.write(f"[tool] {tool}: {text}\n")
44
+ elif event_type == "need_approval":
45
+ self.stdout.write(f"[approval required] {tool}: {text}\n")
46
+ elif event_type == "text":
47
+ self.stdout.write(f"{text}\n")
48
+ elif event_type == "done":
49
+ self.stdout.write("[done]\n")
50
+ elif event_type == "error":
51
+ self.stdout.write(f"[error] {text}\n")
52
+ self.stdout.flush()
53
+ return event
54
+
55
+ def session(self):
56
+ return self._emit("session")
57
+
58
+ def tool(self, tool, text):
59
+ return self._emit("tool", text=text, tool=tool)
60
+
61
+ def need_approval(self, tool, text):
62
+ return self._emit("need_approval", text=text, tool=tool)
63
+
64
+ def text(self, text):
65
+ return self._emit("text", text=text)
66
+
67
+ def done(self):
68
+ return self._emit("done")
69
+
70
+ def error(self, text):
71
+ return self._emit("error", text=text)
72
+
73
+ def log(self, text):
74
+ """Diagnostic line — stderr only, never stdout."""
75
+ self.stderr.write(f"{text}\n")
76
+ self.stderr.flush()