sid-harness 0.3.0__py3-none-any.whl

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 (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/_native.py ADDED
@@ -0,0 +1,1639 @@
1
+ """First-party SID coding loop over routed model tool calls (LQ-576).
2
+
3
+ The agent works on a bounded in-memory workspace. It can inspect and edit only
4
+ that workspace; approved tests execute through the existing sandbox seam. The
5
+ resulting tree is returned to the caller for the existing independent review
6
+ and BellerBuild scan. No external coding-agent UI or SDK is required.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import fnmatch
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ from collections.abc import Awaitable, Callable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any
23
+
24
+ MAX_FILES = 200
25
+ MAX_WORKSPACE_BYTES = 8 * 1024 * 1024
26
+ MAX_FILE_BYTES = 1024 * 1024
27
+ MAX_TURNS = 12
28
+ MAX_TOOL_CALLS = 32
29
+ MAX_TOOL_OUTPUT_CHARS = 6000
30
+
31
+ # SUB-94: project policy. The workspace's root instruction files and its
32
+ # optional ``.sid/config`` are loaded by the ONE loop, so the CLI, the local
33
+ # bridge, Studio chat and the workbench all obey the same precedence. Rule text
34
+ # is concatenated in this order (nothing is silently overridden); the config
35
+ # supplies run options, and explicit request flags are applied on top of it.
36
+ PROJECT_RULE_FILES: tuple[str, ...] = ("AGENTS.md", "SID.md")
37
+ PROJECT_CONFIG_PATH = ".sid/config"
38
+ PROJECT_RULES_PRECEDENCE = (
39
+ ".sid/config options < AGENTS.md < SID.md < explicit request flags "
40
+ "(--skills, --subagent, --plan); a .sid/config that forces plan mode is a "
41
+ "read-only floor a run cannot loosen"
42
+ )
43
+ MAX_PROJECT_RULE_BYTES = 256 * 1024
44
+
45
+ # The loop's base contract stays list/read/write/run_approved_tests. The shell
46
+ # tool is a FIFTH, opt-in tool: it is advertised and honored only when the
47
+ # caller supplies an explicitly approved ``ShellPolicy`` with a sandbox root
48
+ # (``run_native_agent(..., shell_policy=...)``). With no policy — the server
49
+ # path — the loop cannot run a shell at all. Everything below is stdlib-only so
50
+ # the module stays vendorable verbatim into the local harness (``_native.py``).
51
+ SHELL_TOOL_NAME = "run_shell"
52
+ SHELL_MODE_ARGV = "argv"
53
+ SHELL_MODE_SHELL = "shell"
54
+ SHELL_MODES = (SHELL_MODE_ARGV, SHELL_MODE_SHELL)
55
+ DEFAULT_SHELL_TIMEOUT_SECONDS = 120.0
56
+ MAX_SHELL_TIMEOUT_SECONDS = 900.0
57
+ MAX_SHELL_OUTPUT_CHARS = 8000
58
+
59
+ # Programs that are never run, even when a caller allow-lists them: they are
60
+ # destructive, privilege-escalating, network/tunnel, or a second shell (which
61
+ # would defeat the argv/shell policy itself).
62
+ ALWAYS_DENIED_PROGRAMS = frozenset({
63
+ "at", "bcdedit", "bash", "chattr", "chgrp", "chmod", "chown", "cmd",
64
+ "command.com", "crontab", "csh", "curl", "dash", "dd", "del", "diskpart",
65
+ "doas", "erase", "eval", "exec", "fdisk", "firewall-cmd", "fish", "format",
66
+ "ftp", "halt", "icacls", "init", "ip6tables", "iptables", "kill",
67
+ "killall", "ksh", "mkfs", "mount", "nc", "ncat", "net", "netcat", "netsh",
68
+ "nft", "parted", "pkexec", "pkill", "poweroff", "powershell", "pwsh",
69
+ "reboot", "reg", "regedit", "rm", "rmdir", "rsync", "runas", "sc", "scp",
70
+ "schtasks", "service", "setfacl", "sftp", "sh", "shred", "shutdown",
71
+ "socat", "source", "ssh", "su", "sudo", "systemctl", "takeown", "taskkill",
72
+ "telnet", "ufw", "umount", "wget", "wmic", "wsl", "xargs", "zsh",
73
+ })
74
+
75
+ # Default allow-list: the build / install / migrate / format / inspect tools
76
+ # this tool exists for. Callers may replace it (``ShellPolicy(allow=...)``);
77
+ # the ALWAYS_DENIED_PROGRAMS set still applies.
78
+ DEFAULT_ALLOWED_PROGRAMS = frozenset({
79
+ "7z", "ar", "awk", "basename", "black", "cargo", "cat", "cc", "clang",
80
+ "clang++", "clippy-driver", "cmake", "cmp", "coverage", "coverage3",
81
+ "c++", "date", "diff", "dir", "dirname", "dotnet", "echo", "env",
82
+ "eslint", "flake8", "g++", "gcc", "git", "git-lfs", "go", "gofmt",
83
+ "gradle", "gradlew", "grep", "gunzip", "gzip", "head", "isort", "jar",
84
+ "java", "javac", "jest", "ld", "ls", "make", "md", "meson", "mkdir",
85
+ "msbuild", "mvn", "mvnw", "mypy", "ninja", "node", "npm", "npx", "nuget",
86
+ "objcopy", "pip", "pip3", "pipenv", "pnpm", "poetry", "prettier",
87
+ "printenv", "printf", "pwd", "py.test", "pytest", "python", "python3",
88
+ "realpath", "readlink", "rg", "robocopy", "ruff", "rustc", "rustfmt",
89
+ "sed", "sort", "strip", "tar", "tee", "tox", "tr", "tsc", "type", "uniq",
90
+ "unzip", "uv", "uvx", "vite", "wc", "webpack", "where", "which",
91
+ "xcopy", "yarn", "zip",
92
+ })
93
+
94
+ # Programs whose ``-c``/``-e`` flags run inline code. Refused unless the caller
95
+ # explicitly opts in (``allow_inline_code=True``); ``-m``/script paths still work.
96
+ INLINE_CODE_PROGRAMS = frozenset({
97
+ "bun", "deno", "node", "nodejs", "perl", "php", "python", "python3",
98
+ "ruby",
99
+ })
100
+ INLINE_CODE_FLAGS = frozenset({"-c", "-e", "-r", "-p", "--eval", "--print"})
101
+
102
+ # Token sequences refused even when the executable is allow-listed: publishing
103
+ # and irreversible history operations. The harness never commits or pushes.
104
+ DENIED_ARG_SEQUENCES: tuple[tuple[str, ...], ...] = (
105
+ ("cargo", "publish"),
106
+ ("dotnet", "nuget", "push"),
107
+ ("gem", "push"),
108
+ ("git", "checkout", "--"),
109
+ ("git", "clean"),
110
+ ("git", "commit"),
111
+ ("git", "config", "--global"),
112
+ ("git", "config", "--system"),
113
+ ("git", "filter-branch"),
114
+ ("git", "push"),
115
+ ("git", "remote", "add"),
116
+ ("git", "remote", "set-url"),
117
+ ("git", "reset", "--hard"),
118
+ ("npm", "publish"),
119
+ ("npm", "unpublish"),
120
+ ("pip", "uninstall"),
121
+ ("pnpm", "publish"),
122
+ ("twine", "upload"),
123
+ ("yarn", "publish"),
124
+ )
125
+
126
+ # Shell-injection shapes. In argv mode no shell is involved, so these are only
127
+ # refused as tokens (a harmless literal ``>`` is allowed): splitting, pipes,
128
+ # command substitution, and control bytes. In shell mode redirection and
129
+ # backgrounding are refused too.
130
+ INJECTION_ARGV_MARKERS = (";", "|", "`", "$(", "${", "\n", "\r", "\x00")
131
+ INJECTION_SHELL_MARKERS = (*INJECTION_ARGV_MARKERS, "&", ">", "<")
132
+
133
+ # Process environment kept in sandbox mode. Anything else (tokens, cloud keys,
134
+ # database URLs) never reaches the child.
135
+ SANDBOX_ENV_ALLOW = frozenset({
136
+ "CONDA_PREFIX", "HOME", "LANG", "LC_ALL", "PATH", "PATHEXT", "PYTHONPATH",
137
+ "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "USERPROFILE", "VIRTUAL_ENV",
138
+ "WINDIR",
139
+ })
140
+
141
+ _WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:")
142
+
143
+
144
+ # SID Studio guardrail: the observed failure mode was a routed model answering
145
+ # a workspace request with generic assistant boilerplate (declining the file as
146
+ # though it had no editor). The loop's system prompt forbids that family
147
+ # outright and requires that any genuine limitation be named precisely. Kept
148
+ # here (not imported from app.policy) so this module stays stdlib-only and
149
+ # vendorable verbatim.
150
+ WORKSPACE_GUARDRAIL = (
151
+ "You have the project workspace and its tools — the workspace is right "
152
+ "here. NEVER decline the files or ask the user to paste or upload them: "
153
+ "read a file with read_file, or find it with list_files. Do not describe "
154
+ "yourself as a model that has no editor. If a capability you need is "
155
+ "genuinely unavailable (for example no sandbox is configured to run the "
156
+ "tests), name exactly what is missing and the safe next step — never a "
157
+ "generic refusal."
158
+ )
159
+
160
+ # SUB-81: plan mode. The same loop runs read-only: write_file is not offered,
161
+ # every intended edit is submitted through propose_file_change (recorded, never
162
+ # applied), and the model must close with a step plan. The proposal is returned
163
+ # as a diff + readback so an explicit client-side Approve can apply it through
164
+ # the existing write-back path.
165
+ PLAN_MODE_SYSTEM = (
166
+ "PLAN MODE — READ-ONLY. You may inspect the workspace, but you cannot write "
167
+ "it: write_file, apply_patch, delete_file and move_file are disabled. Submit "
168
+ "every intended edit through propose_file_change(path, content), which "
169
+ "records a PROPOSAL and writes nothing. Never say a file was written, saved, "
170
+ "or changed, and never claim the task is done. Finish with a short numbered "
171
+ "step plan: the steps, the files each step touches, and how to verify the "
172
+ "result. The user must approve the plan before anything is applied."
173
+ )
174
+
175
+ PROPOSE_TOOL = {
176
+ "type": "function",
177
+ "function": {
178
+ "name": "propose_file_change",
179
+ "description": (
180
+ "Plan mode only: propose new content for a file. Records the proposal "
181
+ "for the user to approve; writes NOTHING to the workspace."
182
+ ),
183
+ "parameters": {
184
+ "type": "object",
185
+ "properties": {
186
+ "path": {"type": "string"},
187
+ "content": {"type": "string"},
188
+ },
189
+ "required": ["path", "content"],
190
+ "additionalProperties": False,
191
+ },
192
+ },
193
+ }
194
+
195
+ Complete = Callable[[list[dict[str, Any]], list[dict[str, Any]]], Awaitable[dict[str, Any]]]
196
+ RunTests = Callable[[dict[str, str]], Awaitable[dict[str, Any]]]
197
+ # SUB-58: a caller (the local harness) may stop the loop between turns for a
198
+ # clean interrupt. Called with (step, tool_calls_seen); returning True ends the
199
+ # loop with a partial result. Default None keeps the server path unaffected.
200
+ ShouldStop = Callable[[int, int], bool]
201
+ # A caller may supply extra tool schemas plus ONE dispatch seam (the harness's
202
+ # ToolRegistry). Dispatch receives (name, arguments, files, run_tests) and is
203
+ # responsible for routing base tool names back to _tool_result; it must not fork
204
+ # the execution engine. Default None keeps the server path on the base contract.
205
+ Dispatch = Callable[[str, dict[str, Any], dict[str, str], "RunTests"], Any]
206
+
207
+ TOOLS = [
208
+ {
209
+ "type": "function",
210
+ "function": {
211
+ "name": "list_files",
212
+ "description": "List files available in the approved project workspace.",
213
+ "parameters": {"type": "object", "properties": {}, "additionalProperties": False},
214
+ },
215
+ },
216
+ {
217
+ "type": "function",
218
+ "function": {
219
+ "name": "read_file",
220
+ "description": "Read one file from the approved project workspace.",
221
+ "parameters": {
222
+ "type": "object",
223
+ "properties": {"path": {"type": "string"}},
224
+ "required": ["path"],
225
+ "additionalProperties": False,
226
+ },
227
+ },
228
+ },
229
+ {
230
+ "type": "function",
231
+ "function": {
232
+ "name": "write_file",
233
+ "description": "Create or replace a text file in the approved project workspace.",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {
237
+ "path": {"type": "string"},
238
+ "content": {"type": "string"},
239
+ },
240
+ "required": ["path", "content"],
241
+ "additionalProperties": False,
242
+ },
243
+ },
244
+ },
245
+ {
246
+ "type": "function",
247
+ "function": {
248
+ "name": "apply_patch",
249
+ "description": (
250
+ "Edit one file by anchored replacements. Each edit supplies the exact "
251
+ "existing text (old) that must occur exactly once and its replacement "
252
+ "(new); use a few surrounding lines as the anchor. Returns a unified "
253
+ "diff of the applied change. Prefer this over write_file: it sends only "
254
+ "the changed region, not the whole file."
255
+ ),
256
+ "parameters": {
257
+ "type": "object",
258
+ "properties": {
259
+ "path": {"type": "string"},
260
+ "edits": {
261
+ "type": "array",
262
+ "minItems": 1,
263
+ "items": {
264
+ "type": "object",
265
+ "properties": {
266
+ "old": {"type": "string"},
267
+ "new": {"type": "string"},
268
+ },
269
+ "required": ["old", "new"],
270
+ "additionalProperties": False,
271
+ },
272
+ },
273
+ },
274
+ "required": ["path", "edits"],
275
+ "additionalProperties": False,
276
+ },
277
+ },
278
+ },
279
+ {
280
+ "type": "function",
281
+ "function": {
282
+ "name": "delete_file",
283
+ "description": (
284
+ "Delete one file from the workspace. Requires confirm=true: a deletion "
285
+ "is never silent, and it is applied to disk only when the caller "
286
+ "approves the run's diff."
287
+ ),
288
+ "parameters": {
289
+ "type": "object",
290
+ "properties": {
291
+ "path": {"type": "string"},
292
+ "confirm": {"type": "boolean"},
293
+ },
294
+ "required": ["path", "confirm"],
295
+ "additionalProperties": False,
296
+ },
297
+ },
298
+ },
299
+ {
300
+ "type": "function",
301
+ "function": {
302
+ "name": "move_file",
303
+ "description": (
304
+ "Move or rename one file inside the workspace. Requires confirm=true "
305
+ "and never overwrites an existing destination."
306
+ ),
307
+ "parameters": {
308
+ "type": "object",
309
+ "properties": {
310
+ "from": {"type": "string"},
311
+ "to": {"type": "string"},
312
+ "confirm": {"type": "boolean"},
313
+ },
314
+ "required": ["from", "to", "confirm"],
315
+ "additionalProperties": False,
316
+ },
317
+ },
318
+ },
319
+ {
320
+ "type": "function",
321
+ "function": {
322
+ "name": "grep",
323
+ "description": (
324
+ "Search file contents across the workspace. Returns path, line number, "
325
+ "and line text for each match. Honours the shared path denylist and an "
326
+ "optional exclude list of glob patterns."
327
+ ),
328
+ "parameters": {
329
+ "type": "object",
330
+ "properties": {
331
+ "pattern": {"type": "string"},
332
+ "path": {
333
+ "type": "string",
334
+ "description": "Optional file or directory prefix to search.",
335
+ },
336
+ "regex": {"type": "boolean"},
337
+ "case_sensitive": {"type": "boolean"},
338
+ "exclude": {"type": "array", "items": {"type": "string"}},
339
+ "max_results": {"type": "integer"},
340
+ },
341
+ "required": ["pattern"],
342
+ "additionalProperties": False,
343
+ },
344
+ },
345
+ },
346
+ {
347
+ "type": "function",
348
+ "function": {
349
+ "name": "glob",
350
+ "description": (
351
+ "Find workspace files by glob pattern (for example **/*.py). Honours "
352
+ "the shared path denylist and an optional exclude list."
353
+ ),
354
+ "parameters": {
355
+ "type": "object",
356
+ "properties": {
357
+ "pattern": {"type": "string"},
358
+ "exclude": {"type": "array", "items": {"type": "string"}},
359
+ "max_results": {"type": "integer"},
360
+ },
361
+ "required": ["pattern"],
362
+ "additionalProperties": False,
363
+ },
364
+ },
365
+ },
366
+ {
367
+ "type": "function",
368
+ "function": {
369
+ "name": "run_approved_tests",
370
+ "description": "Run the test command the user approved before this task.",
371
+ "parameters": {"type": "object", "properties": {}, "additionalProperties": False},
372
+ },
373
+ },
374
+ ]
375
+
376
+ # Opt-in fifth tool. NOT part of TOOLS: the base contract is unchanged and the
377
+ # server path never advertises it. A caller that supplies an approved
378
+ # ``ShellPolicy`` gets this schema appended for the run.
379
+ SHELL_TOOL = {
380
+ "type": "function",
381
+ "function": {
382
+ "name": SHELL_TOOL_NAME,
383
+ "description": (
384
+ "Run ONE command the user explicitly approved for this task inside "
385
+ "the sandbox workspace (build, install, migrate, format, or read-only "
386
+ "git). Pass an argv list when the policy is 'argv' mode; a single "
387
+ "command line only when the policy is 'shell' mode. The sandbox root "
388
+ "is the working directory; the command may not escape it."
389
+ ),
390
+ "parameters": {
391
+ "type": "object",
392
+ "properties": {
393
+ "argv": {
394
+ "type": "array",
395
+ "items": {"type": "string"},
396
+ "description": "Command as an argv list (preferred; no shell).",
397
+ },
398
+ "command": {
399
+ "type": "string",
400
+ "description": "Command line (only when the policy is 'shell' mode).",
401
+ },
402
+ "cwd": {
403
+ "type": "string",
404
+ "description": "Relative working directory inside the sandbox root.",
405
+ },
406
+ "timeout_seconds": {
407
+ "type": "number",
408
+ "description": "Requested wall-clock timeout; clamped by policy.",
409
+ },
410
+ },
411
+ "additionalProperties": False,
412
+ },
413
+ },
414
+ }
415
+
416
+ # Tools that mutate the workspace. Plan mode disables all of them (SUB-81) and
417
+ # the harness registry gates them behind the approved-plan check (SUB-56).
418
+ MUTATING_TOOL_NAMES = frozenset(
419
+ {"write_file", "apply_patch", "delete_file", "move_file"}
420
+ )
421
+
422
+ DEFAULT_MAX_SEARCH_RESULTS = 100
423
+ MAX_SEARCH_RESULTS = 500
424
+ MAX_GREP_LINE_CHARS = 400
425
+
426
+
427
+ class NativeAgentError(ValueError):
428
+ """The first-party agent could not safely complete the requested loop."""
429
+
430
+
431
+ class NativeAgentInputError(NativeAgentError):
432
+ """The submitted workspace is outside the native agent's accepted bounds."""
433
+
434
+
435
+ def _safe_path(raw: Any) -> str:
436
+ if not isinstance(raw, str) or not raw.strip():
437
+ raise NativeAgentError("tool path must be a non-empty relative path")
438
+ path = raw.strip().replace("\\", "/")
439
+ parsed = PurePosixPath(path)
440
+ if parsed.is_absolute() or any(part in ("", ".", "..") for part in parsed.parts):
441
+ raise NativeAgentError("tool path must stay inside the project workspace")
442
+ lowered = [part.lower() for part in parsed.parts]
443
+ leaf = lowered[-1]
444
+ if any(part in {".git", "node_modules", ".venv", "venv", "__pycache__"} for part in lowered):
445
+ raise NativeAgentError("agent tools cannot access dependency or VCS directories")
446
+ if any(
447
+ part == ".env" or (part.startswith(".env.") and part != ".env.example")
448
+ for part in lowered
449
+ ):
450
+ raise NativeAgentError("agent tools cannot read or write environment secret files")
451
+ if leaf.endswith((".pem", ".key", ".p12", ".pfx")):
452
+ raise NativeAgentError("agent tools cannot read or write private-key files")
453
+ return str(parsed)
454
+
455
+
456
+ def _glob_segments(pattern: str) -> list[str]:
457
+ return [part for part in pattern.replace("\\", "/").strip("/").split("/") if part]
458
+
459
+
460
+ def _match_glob_segments(pattern_parts: list[str], path_parts: list[str]) -> bool:
461
+ if not pattern_parts:
462
+ return not path_parts
463
+ head = pattern_parts[0]
464
+ if head == "**":
465
+ return any(
466
+ _match_glob_segments(pattern_parts[1:], path_parts[index:])
467
+ for index in range(len(path_parts) + 1)
468
+ )
469
+ if not path_parts:
470
+ return False
471
+ return fnmatch.fnmatchcase(path_parts[0], head) and _match_glob_segments(
472
+ pattern_parts[1:], path_parts[1:]
473
+ )
474
+
475
+
476
+ def _glob_match(pattern: str, rel: str) -> bool:
477
+ """Path-aware glob: ``*``/``?`` stay inside a segment, ``**`` spans them."""
478
+ if not pattern:
479
+ return False
480
+ if "/" not in pattern.replace("\\", "/"):
481
+ return fnmatch.fnmatchcase(rel.rsplit("/", 1)[-1], pattern)
482
+ return _match_glob_segments(_glob_segments(pattern), _glob_segments(rel))
483
+
484
+
485
+ def _matches_any(patterns: object, rel: str) -> bool:
486
+ if not isinstance(patterns, (list, tuple)):
487
+ return False
488
+ return any(isinstance(item, str) and _glob_match(item, rel) for item in patterns)
489
+
490
+
491
+ def _search_path_ok(rel: str, scope: str | None) -> bool:
492
+ if not scope:
493
+ return True
494
+ scope = scope.replace("\\", "/").strip("/")
495
+ return rel == scope or rel.startswith(scope + "/")
496
+
497
+
498
+ def _search_limit(raw: object) -> int:
499
+ try:
500
+ value = int(raw) # type: ignore[arg-type]
501
+ except (TypeError, ValueError):
502
+ return DEFAULT_MAX_SEARCH_RESULTS
503
+ if value <= 0:
504
+ return DEFAULT_MAX_SEARCH_RESULTS
505
+ return min(value, MAX_SEARCH_RESULTS)
506
+
507
+
508
+ def _file_changed_diff(path: str, before: str, after: str | None) -> str:
509
+ return "".join(
510
+ difflib.unified_diff(
511
+ before.splitlines(True),
512
+ (after or "").splitlines(True),
513
+ fromfile=f"a/{path}",
514
+ tofile=f"b/{path}" if after is not None else "/dev/null",
515
+ )
516
+ )
517
+
518
+
519
+ def _grep_result(
520
+ arguments: dict[str, Any],
521
+ files: dict[str, str],
522
+ *,
523
+ max_results: int = DEFAULT_MAX_SEARCH_RESULTS,
524
+ ) -> dict[str, Any]:
525
+ pattern = arguments.get("pattern")
526
+ if not isinstance(pattern, str) or not pattern:
527
+ return {"error": "pattern must be a non-empty string"}
528
+ use_regex = bool(arguments.get("regex", True))
529
+ case_sensitive = bool(arguments.get("case_sensitive", True))
530
+ scope = arguments.get("path")
531
+ if scope is not None and not isinstance(scope, str):
532
+ return {"error": "path must be a string"}
533
+ try:
534
+ rx = re.compile(
535
+ pattern if use_regex else re.escape(pattern),
536
+ 0 if case_sensitive else re.IGNORECASE,
537
+ )
538
+ except re.error as exc:
539
+ return {"error": f"invalid grep pattern: {exc}"}
540
+ limit = _search_limit(arguments.get("max_results", max_results))
541
+ exclude = arguments.get("exclude")
542
+
543
+ matches: list[dict[str, Any]] = []
544
+ truncated = False
545
+ for path in sorted(files):
546
+ if not _search_path_ok(path, scope) or _matches_any(exclude, path):
547
+ continue
548
+ try:
549
+ _safe_path(path)
550
+ except NativeAgentError:
551
+ continue
552
+ for number, line in enumerate(files[path].splitlines(), 1):
553
+ if not rx.search(line):
554
+ continue
555
+ if len(matches) >= limit:
556
+ truncated = True
557
+ break
558
+ matches.append(
559
+ {
560
+ "path": path,
561
+ "line": number,
562
+ "text": line[:MAX_GREP_LINE_CHARS],
563
+ }
564
+ )
565
+ if truncated:
566
+ break
567
+ return {"matches": matches, "count": len(matches), "truncated": truncated}
568
+
569
+
570
+ def _glob_result(
571
+ arguments: dict[str, Any],
572
+ files: dict[str, str],
573
+ *,
574
+ max_results: int = DEFAULT_MAX_SEARCH_RESULTS,
575
+ ) -> dict[str, Any]:
576
+ pattern = arguments.get("pattern")
577
+ if not isinstance(pattern, str) or not pattern:
578
+ return {"error": "pattern must be a non-empty string"}
579
+ limit = _search_limit(arguments.get("max_results", max_results))
580
+ exclude = arguments.get("exclude")
581
+ matched: list[str] = []
582
+ truncated = False
583
+ for path in sorted(files):
584
+ if _matches_any(exclude, path) or not _glob_match(pattern, path):
585
+ continue
586
+ try:
587
+ _safe_path(path)
588
+ except NativeAgentError:
589
+ continue
590
+ if len(matched) >= limit:
591
+ truncated = True
592
+ break
593
+ matched.append(path)
594
+ return {"files": matched, "count": len(matched), "truncated": truncated}
595
+
596
+
597
+ def _apply_patch_result(
598
+ arguments: dict[str, Any],
599
+ files: dict[str, str],
600
+ *,
601
+ max_file_bytes: int = MAX_FILE_BYTES,
602
+ max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
603
+ ) -> dict[str, Any]:
604
+ try:
605
+ path = _safe_path(arguments.get("path"))
606
+ except NativeAgentError as exc:
607
+ return {"error": str(exc)}
608
+ if path not in files:
609
+ return {"error": f"file not found: {path}"}
610
+ edits = arguments.get("edits")
611
+ if not isinstance(edits, list) or not edits:
612
+ return {"error": "edits must be a non-empty array of {old, new} anchors"}
613
+ original = files[path]
614
+ updated = original
615
+ for index, edit in enumerate(edits, 1):
616
+ if not isinstance(edit, dict):
617
+ return {"error": f"edit {index} must be an object with old/new"}
618
+ old = edit.get("old")
619
+ new = edit.get("new")
620
+ if not isinstance(old, str) or not isinstance(new, str):
621
+ return {"error": f"edit {index} requires string old and new"}
622
+ if not old:
623
+ return {"error": f"edit {index} anchor (old) must be non-empty"}
624
+ occurrences = updated.count(old)
625
+ if occurrences == 0:
626
+ return {
627
+ "error": f"edit {index} anchor not found in {path}",
628
+ "anchor": old[:MAX_GREP_LINE_CHARS],
629
+ }
630
+ if occurrences > 1:
631
+ return {
632
+ "error": (
633
+ f"edit {index} anchor is not unique in {path} "
634
+ f"({occurrences} occurrences); include more surrounding context"
635
+ ),
636
+ "occurrences": occurrences,
637
+ }
638
+ updated = updated.replace(old, new, 1)
639
+ size = len(updated.encode("utf-8"))
640
+ if size > max_file_bytes:
641
+ return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
642
+ projected_size = sum(
643
+ len(value.encode("utf-8")) for key, value in files.items() if key != path
644
+ ) + size
645
+ if projected_size > max_workspace_bytes:
646
+ return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
647
+ files[path] = updated
648
+ diff = _file_changed_diff(path, original, updated)
649
+ additions = sum(
650
+ 1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++")
651
+ )
652
+ deletions = sum(
653
+ 1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---")
654
+ )
655
+ return {
656
+ "patched": path,
657
+ "edits": len(edits),
658
+ "bytes": size,
659
+ "additions": additions,
660
+ "deletions": deletions,
661
+ "diff": diff,
662
+ }
663
+
664
+
665
+ def _delete_file_result(
666
+ arguments: dict[str, Any], files: dict[str, str]
667
+ ) -> dict[str, Any]:
668
+ try:
669
+ path = _safe_path(arguments.get("path"))
670
+ except NativeAgentError as exc:
671
+ return {"error": str(exc)}
672
+ if arguments.get("confirm") is not True:
673
+ return {
674
+ "error": "delete_file requires explicit approval: pass confirm=true",
675
+ "path": path,
676
+ "pending": True,
677
+ }
678
+ if path not in files:
679
+ return {"error": f"file not found: {path}"}
680
+ removed = files.pop(path)
681
+ return {
682
+ "deleted": path,
683
+ "approved": True,
684
+ "bytes": len(removed.encode("utf-8")),
685
+ "diff": _file_changed_diff(path, removed, None),
686
+ }
687
+
688
+
689
+ def _move_file_result(
690
+ arguments: dict[str, Any], files: dict[str, str]
691
+ ) -> dict[str, Any]:
692
+ try:
693
+ source = _safe_path(arguments.get("from"))
694
+ destination = _safe_path(arguments.get("to"))
695
+ except NativeAgentError as exc:
696
+ return {"error": str(exc)}
697
+ if arguments.get("confirm") is not True:
698
+ return {
699
+ "error": "move_file requires explicit approval: pass confirm=true",
700
+ "from": source,
701
+ "to": destination,
702
+ "pending": True,
703
+ }
704
+ if source not in files:
705
+ return {"error": f"file not found: {source}"}
706
+ if source == destination:
707
+ return {"error": "from and to must be different paths"}
708
+ if destination in files:
709
+ return {"error": f"destination already exists: {destination}"}
710
+ content = files.pop(source)
711
+ files[destination] = content
712
+ return {
713
+ "moved": {"from": source, "to": destination},
714
+ "approved": True,
715
+ "bytes": len(content.encode("utf-8")),
716
+ }
717
+
718
+
719
+ def _validate_workspace(
720
+ files: dict[str, str],
721
+ *,
722
+ max_files: int = MAX_FILES,
723
+ max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
724
+ max_file_bytes: int = MAX_FILE_BYTES,
725
+ ) -> dict[str, str]:
726
+ if not isinstance(files, dict) or not files:
727
+ raise NativeAgentInputError("select a non-empty text workspace before running")
728
+ if len(files) > max_files:
729
+ raise NativeAgentInputError(f"workspace exceeds the {max_files}-file limit")
730
+ normalized: dict[str, str] = {}
731
+ total_bytes = 0
732
+ for raw_path, content in files.items():
733
+ try:
734
+ path = _safe_path(raw_path)
735
+ except NativeAgentError as exc:
736
+ raise NativeAgentInputError(str(exc)) from exc
737
+ if path in normalized:
738
+ raise NativeAgentInputError(f"workspace contains duplicate normalized path {path!r}")
739
+ if not isinstance(content, str):
740
+ raise NativeAgentInputError(f"workspace file {path!r} must contain text")
741
+ size = len(content.encode("utf-8"))
742
+ if size > max_file_bytes:
743
+ raise NativeAgentInputError(f"workspace file {path!r} exceeds the {max_file_bytes}-byte limit")
744
+ total_bytes += size
745
+ normalized[path] = content
746
+ if total_bytes > max_workspace_bytes:
747
+ raise NativeAgentInputError(f"workspace exceeds the {max_workspace_bytes}-byte limit")
748
+ return normalized
749
+
750
+
751
+ def _project_rule_path(raw: Any) -> str:
752
+ path = str(raw).replace("\\", "/").strip()
753
+ while path.startswith("./"):
754
+ path = path[2:]
755
+ return path
756
+
757
+
758
+ def _sha256_text(text: str) -> str:
759
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
760
+
761
+
762
+ def load_project_rules(workspace_files: dict[str, str] | None) -> dict[str, Any]:
763
+ """Load the workspace's root project policy in ONE precedence order.
764
+
765
+ ``AGENTS.md`` then ``SID.md`` carry the binding instructions; the optional
766
+ ``.sid/config`` JSON object supplies run options (``skills``,
767
+ ``skills_root``, ``subagents``, ``plan``). The returned block names every
768
+ file with its ``sha256`` and a digest over the whole policy, so a receipt
769
+ can never claim a run that silently ignored project rules. A malformed
770
+ config fails closed — it is project policy, not a preference.
771
+ """
772
+ normalized: dict[str, str] = {}
773
+ for raw_path, content in (workspace_files or {}).items():
774
+ if isinstance(content, str):
775
+ normalized[_project_rule_path(raw_path)] = content
776
+
777
+ files: list[dict[str, Any]] = []
778
+ instructions: list[str] = []
779
+ config: dict[str, Any] = {}
780
+ for path in (*PROJECT_RULE_FILES, PROJECT_CONFIG_PATH):
781
+ content = normalized.get(path)
782
+ if content is None:
783
+ continue
784
+ size = len(content.encode("utf-8"))
785
+ if size > MAX_PROJECT_RULE_BYTES:
786
+ raise NativeAgentInputError(
787
+ f"project policy file {path!r} exceeds the "
788
+ f"{MAX_PROJECT_RULE_BYTES}-byte limit"
789
+ )
790
+ digest = _sha256_text(content)
791
+ files.append({"path": path, "bytes": size, "sha256": digest})
792
+ if path == PROJECT_CONFIG_PATH:
793
+ try:
794
+ parsed = json.loads(content)
795
+ except json.JSONDecodeError as exc:
796
+ raise NativeAgentInputError(
797
+ f"project policy {path!r} is not valid JSON "
798
+ f"(line {exc.lineno}: {exc.msg}); refusing to run without it"
799
+ ) from exc
800
+ if not isinstance(parsed, dict):
801
+ raise NativeAgentInputError(
802
+ f"project policy {path!r} must be a JSON object; "
803
+ "refusing to run without it"
804
+ )
805
+ config = parsed
806
+ continue
807
+ body = content.strip()
808
+ if body:
809
+ instructions.append(f"## {path} (sha256 {digest})\n{body}")
810
+
811
+ policy_digest = hashlib.sha256()
812
+ for entry in files:
813
+ policy_digest.update(entry["path"].encode("utf-8"))
814
+ policy_digest.update(b"\x00")
815
+ policy_digest.update(entry["sha256"].encode("ascii"))
816
+ policy_digest.update(b"\n")
817
+ return {
818
+ "precedence": PROJECT_RULES_PRECEDENCE,
819
+ "files": files,
820
+ "sources": [entry["path"] for entry in files],
821
+ "instructions": "\n\n".join(instructions),
822
+ "config": config,
823
+ "digest": policy_digest.hexdigest(),
824
+ }
825
+
826
+
827
+ def _tool_result(
828
+ name: str,
829
+ arguments: dict[str, Any],
830
+ files: dict[str, str],
831
+ run_tests: RunTests,
832
+ *,
833
+ max_tool_output_chars: int = MAX_TOOL_OUTPUT_CHARS,
834
+ max_file_bytes: int = MAX_FILE_BYTES,
835
+ max_files: int = MAX_FILES,
836
+ max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
837
+ ) -> Awaitable[dict[str, Any]] | dict[str, Any]:
838
+ if name == "list_files":
839
+ return {"files": sorted(files)}
840
+ if name == "read_file":
841
+ try:
842
+ path = _safe_path(arguments.get("path"))
843
+ except NativeAgentError as exc:
844
+ return {"error": str(exc)}
845
+ if path not in files:
846
+ return {"error": f"file not found: {path}"}
847
+ return {"path": path, "content": files[path][:max_tool_output_chars], "truncated": len(files[path]) > max_tool_output_chars}
848
+ if name == "write_file":
849
+ try:
850
+ path = _safe_path(arguments.get("path"))
851
+ except NativeAgentError as exc:
852
+ return {"error": str(exc)}
853
+ content = arguments.get("content")
854
+ if not isinstance(content, str):
855
+ return {"error": "content must be a string"}
856
+ if len(content.encode("utf-8")) > max_file_bytes:
857
+ return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
858
+ if path not in files and len(files) >= max_files:
859
+ return {"error": f"workspace exceeds the {max_files}-file limit"}
860
+ projected_size = sum(len(value.encode("utf-8")) for key, value in files.items() if key != path)
861
+ projected_size += len(content.encode("utf-8"))
862
+ if projected_size > max_workspace_bytes:
863
+ return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
864
+ files[path] = content
865
+ return {"written": path, "bytes": len(content.encode("utf-8"))}
866
+ if name == "apply_patch":
867
+ return _apply_patch_result(
868
+ arguments,
869
+ files,
870
+ max_file_bytes=max_file_bytes,
871
+ max_workspace_bytes=max_workspace_bytes,
872
+ )
873
+ if name == "delete_file":
874
+ return _delete_file_result(arguments, files)
875
+ if name == "move_file":
876
+ return _move_file_result(arguments, files)
877
+ if name == "grep":
878
+ return _grep_result(arguments, files)
879
+ if name == "glob":
880
+ return _glob_result(arguments, files)
881
+ if name == "run_approved_tests":
882
+ return run_tests(files)
883
+ return {"error": f"unknown tool: {name}"}
884
+
885
+
886
+ @dataclass(frozen=True)
887
+ class ShellPolicy:
888
+ """The explicit approval and boundary for the ``run_shell`` tool.
889
+
890
+ Every field defaults to the refusing state: ``approved=False`` means the
891
+ tool is never advertised or executed, and a missing ``root`` means there is
892
+ no sandbox to run in. A caller that wants the tool must opt in with an
893
+ approved policy, a real sandbox root, and (optionally) its own allow/deny
894
+ lists — the built-in dangerous-program set is always added to the deny list.
895
+
896
+ ``sandbox=True`` is a requirement, not a claim of isolation: every command
897
+ runs confined to ``root`` with a scrubbed environment and no shell unless
898
+ ``mode`` says so. The built-in local runner reports ``isolated=False`` so a
899
+ caller never mistakes it for a microVM boundary.
900
+ """
901
+
902
+ approved: bool = False
903
+ root: str | None = None
904
+ sandbox: bool = True
905
+ mode: str = SHELL_MODE_ARGV
906
+ allow: tuple[str, ...] | None = None
907
+ deny: tuple[str, ...] = ()
908
+ allow_inline_code: bool = False
909
+ approver: Callable[[str], bool] | None = None
910
+ max_timeout_seconds: float = DEFAULT_SHELL_TIMEOUT_SECONDS
911
+ max_output_chars: int = MAX_SHELL_OUTPUT_CHARS
912
+
913
+
914
+ def _shell_refusal(command: str, argv: list[str] | None, detail: str) -> dict[str, Any]:
915
+ return {
916
+ "tool": SHELL_TOOL_NAME,
917
+ "command": command,
918
+ "argv": argv,
919
+ "cwd": None,
920
+ "executed": False,
921
+ "exit_code": None,
922
+ "passed": None,
923
+ "timed_out": False,
924
+ "duration_ms": 0,
925
+ "output": "",
926
+ "output_truncated": False,
927
+ "sandbox": True,
928
+ "isolated": False,
929
+ "backend": "none",
930
+ "detail": detail,
931
+ }
932
+
933
+
934
+ def _program_name(token: str) -> str:
935
+ """Bare, lower-cased program name for policy matching.
936
+
937
+ ``C:\\Python312\\python.exe``, ``python3.12``, and ``python`` all normalise
938
+ to a name the allow/deny lists can match. Absolute program paths are
939
+ permitted only because the basename must still be allow-listed.
940
+ """
941
+ name = str(token).replace("\\", "/").rsplit("/", 1)[-1].lower()
942
+ if name.endswith(".exe"):
943
+ name = name[: -len(".exe")]
944
+ if re.fullmatch(r"python[0-9.]*", name):
945
+ return "python"
946
+ if re.fullmatch(r"pip[0-9.]*", name):
947
+ return "pip"
948
+ return name
949
+
950
+
951
+ _PARENT_ESCAPE = re.compile(r"\.\.(?:[/\\]|$)")
952
+
953
+
954
+ def _escapes_workspace(token: str) -> bool:
955
+ normalized = str(token).replace("\\", "/")
956
+ if normalized.startswith(("/", "~")) or _WINDOWS_ABSOLUTE.match(normalized):
957
+ return True
958
+ return bool(_PARENT_ESCAPE.search(normalized))
959
+
960
+
961
+ def _has_injection(text: str, markers: tuple[str, ...]) -> str | None:
962
+ for marker in markers:
963
+ if marker in text:
964
+ return marker
965
+ return None
966
+
967
+
968
+ def _sandbox_environment() -> dict[str, str]:
969
+ return {
970
+ key: value
971
+ for key, value in os.environ.items()
972
+ if key.upper() in SANDBOX_ENV_ALLOW
973
+ }
974
+
975
+
976
+ def _capture_shell(
977
+ invocation: list[str] | str,
978
+ *,
979
+ cwd: Path,
980
+ timeout: float,
981
+ output_limit: int,
982
+ use_shell: bool,
983
+ env: dict[str, str],
984
+ ) -> tuple[int | None, bool, bytes, bool]:
985
+ """Run one bounded command; keep at most ``output_limit`` bytes of output."""
986
+ process = subprocess.Popen(
987
+ invocation,
988
+ cwd=str(cwd),
989
+ shell=use_shell,
990
+ stdin=subprocess.DEVNULL,
991
+ stdout=subprocess.PIPE,
992
+ stderr=subprocess.STDOUT,
993
+ env=env,
994
+ )
995
+ kept: list[bytes] = []
996
+ state = {"kept": 0, "truncated": False}
997
+
998
+ def drain() -> None:
999
+ stream = process.stdout
1000
+ if stream is None:
1001
+ return
1002
+ while True:
1003
+ block = stream.read(65536)
1004
+ if not block:
1005
+ break
1006
+ room = output_limit - state["kept"]
1007
+ if room > 0:
1008
+ chunk = block[:room]
1009
+ kept.append(chunk)
1010
+ state["kept"] += len(chunk)
1011
+ if len(chunk) < len(block):
1012
+ state["truncated"] = True
1013
+ else:
1014
+ state["truncated"] = True
1015
+
1016
+ reader = threading.Thread(target=drain, name="sid-shell-output", daemon=True)
1017
+ reader.start()
1018
+ timed_out = False
1019
+ try:
1020
+ exit_code: int | None = process.wait(timeout=timeout)
1021
+ except subprocess.TimeoutExpired:
1022
+ timed_out = True
1023
+ process.kill()
1024
+ try:
1025
+ exit_code = process.wait(timeout=5)
1026
+ except subprocess.TimeoutExpired: # pragma: no cover - kill is best-effort
1027
+ exit_code = None
1028
+ reader.join(timeout=10)
1029
+ if process.stdout is not None:
1030
+ try:
1031
+ process.stdout.close()
1032
+ except OSError:
1033
+ pass
1034
+ return exit_code, timed_out, b"".join(kept), state["truncated"]
1035
+
1036
+
1037
+ def run_shell_tool(
1038
+ arguments: dict[str, Any] | None,
1039
+ policy: ShellPolicy | None,
1040
+ ) -> dict[str, Any]:
1041
+ """Validate one ``run_shell`` request against ``policy`` and run it.
1042
+
1043
+ Refusals are returned as a result dict (``executed=False`` plus ``detail``),
1044
+ never raised, so the loop can show the model exactly what the policy
1045
+ refused. The command string and exit code are always in the returned block,
1046
+ which is what the run receipt records.
1047
+ """
1048
+ arguments = arguments if isinstance(arguments, dict) else {}
1049
+ raw_command = arguments.get("command")
1050
+ raw_argv = arguments.get("argv")
1051
+ argv_input = (
1052
+ [str(token) for token in raw_argv]
1053
+ if isinstance(raw_argv, (list, tuple)) and all(isinstance(t, str) for t in raw_argv)
1054
+ else None
1055
+ )
1056
+ display = (
1057
+ str(raw_command)
1058
+ if isinstance(raw_command, str) and raw_command.strip()
1059
+ else (" ".join(argv_input) if argv_input else "")
1060
+ )
1061
+ if policy is None or not policy.approved:
1062
+ return _shell_refusal(
1063
+ display,
1064
+ argv_input,
1065
+ "shell execution requires an explicit approval before this task",
1066
+ )
1067
+ if not policy.sandbox:
1068
+ return _shell_refusal(display, argv_input, "shell execution requires the sandbox")
1069
+ if policy.mode not in SHELL_MODES:
1070
+ return _shell_refusal(display, argv_input, f"unknown shell mode: {policy.mode!r}")
1071
+ if policy.root is None:
1072
+ return _shell_refusal(
1073
+ display, argv_input, "no sandbox root was supplied; refusing to run on the host"
1074
+ )
1075
+ try:
1076
+ root = Path(policy.root).expanduser().resolve()
1077
+ except OSError as exc:
1078
+ return _shell_refusal(display, argv_input, f"invalid sandbox root: {exc}")
1079
+ if not root.is_dir():
1080
+ return _shell_refusal(display, argv_input, f"sandbox root is not a directory: {root}")
1081
+
1082
+ if policy.mode == SHELL_MODE_ARGV:
1083
+ if isinstance(raw_command, str) and raw_command.strip():
1084
+ return _shell_refusal(
1085
+ display, argv_input, "argv mode: a shell command string is refused; pass argv"
1086
+ )
1087
+ if not argv_input:
1088
+ return _shell_refusal(display, argv_input, "argv mode requires a non-empty argv list")
1089
+ if any(not token for token in argv_input):
1090
+ return _shell_refusal(display, argv_input, "argv must not contain empty tokens")
1091
+ argv = list(argv_input)
1092
+ detail_markers = INJECTION_ARGV_MARKERS
1093
+ use_shell = False
1094
+ invocation: list[str] | str = argv
1095
+ else:
1096
+ if argv_input is not None:
1097
+ return _shell_refusal(
1098
+ display, argv_input, "shell mode takes one command line, not an argv list"
1099
+ )
1100
+ if not isinstance(raw_command, str) or not raw_command.strip():
1101
+ return _shell_refusal(display, argv_input, "shell mode requires a command string")
1102
+ command = raw_command.strip()
1103
+ display = command
1104
+ detail_markers = INJECTION_SHELL_MARKERS
1105
+ argv = command.split()
1106
+ use_shell = True
1107
+ invocation = command
1108
+
1109
+ if any("\x00" in token for token in argv):
1110
+ return _shell_refusal(display, argv, "command must not contain NUL bytes")
1111
+ marker = _has_injection(display, detail_markers)
1112
+ if marker is not None:
1113
+ return _shell_refusal(
1114
+ display, argv, f"command shape is refused (contains {marker!r})"
1115
+ )
1116
+
1117
+ program = _program_name(argv[0])
1118
+ allowed = DEFAULT_ALLOWED_PROGRAMS if policy.allow is None else frozenset(policy.allow)
1119
+ denied = ALWAYS_DENIED_PROGRAMS | frozenset(policy.deny)
1120
+ if program in denied:
1121
+ return _shell_refusal(display, argv, f"program is denied by policy: {program!r}")
1122
+ if program not in allowed:
1123
+ return _shell_refusal(display, argv, f"program is not allow-listed: {program!r}")
1124
+ if (
1125
+ not policy.allow_inline_code
1126
+ and program in INLINE_CODE_PROGRAMS
1127
+ and any(token in INLINE_CODE_FLAGS for token in argv[1:])
1128
+ ):
1129
+ return _shell_refusal(
1130
+ display, argv, "inline code execution (-c/-e) is not approved by policy"
1131
+ )
1132
+ lowered = [token.lower() for token in argv]
1133
+ for sequence in DENIED_ARG_SEQUENCES:
1134
+ if any(
1135
+ tuple(lowered[index : index + len(sequence)]) == sequence
1136
+ for index in range(len(lowered) - len(sequence) + 1)
1137
+ ):
1138
+ return _shell_refusal(
1139
+ display, argv, "command shape is denied by policy: " + " ".join(sequence)
1140
+ )
1141
+ for token in argv[1:]:
1142
+ if _escapes_workspace(token):
1143
+ return _shell_refusal(
1144
+ display, argv, f"command must stay inside the sandbox: {token!r}"
1145
+ )
1146
+ if policy.approver is not None:
1147
+ try:
1148
+ approved = bool(policy.approver(display))
1149
+ except Exception as exc: # an approver bug must fail closed
1150
+ return _shell_refusal(
1151
+ display, argv, f"approval check failed: {type(exc).__name__}: {exc}"
1152
+ )
1153
+ if not approved:
1154
+ return _shell_refusal(display, argv, "command was not approved")
1155
+
1156
+ raw_cwd = arguments.get("cwd") or "."
1157
+ if not isinstance(raw_cwd, str) or not raw_cwd.strip():
1158
+ return _shell_refusal(display, argv, "cwd must be a non-empty relative path")
1159
+ if _escapes_workspace(raw_cwd):
1160
+ return _shell_refusal(display, argv, "cwd must stay inside the sandbox root")
1161
+ try:
1162
+ cwd = (root / raw_cwd.replace("\\", "/").strip("/")).resolve()
1163
+ except OSError as exc:
1164
+ return _shell_refusal(display, argv, f"invalid cwd: {exc}")
1165
+ if cwd != root and root not in cwd.parents:
1166
+ return _shell_refusal(display, argv, "cwd escapes the sandbox root")
1167
+ if not cwd.is_dir():
1168
+ return _shell_refusal(display, argv, f"cwd is not a directory: {raw_cwd}")
1169
+
1170
+ try:
1171
+ timeout = float(arguments.get("timeout_seconds") or policy.max_timeout_seconds)
1172
+ except (TypeError, ValueError):
1173
+ timeout = policy.max_timeout_seconds
1174
+ if timeout <= 0:
1175
+ timeout = policy.max_timeout_seconds
1176
+ timeout = min(timeout, MAX_SHELL_TIMEOUT_SECONDS)
1177
+ output_limit = max(1, min(int(policy.max_output_chars), MAX_SHELL_OUTPUT_CHARS))
1178
+
1179
+ started = time.monotonic()
1180
+ try:
1181
+ exit_code, timed_out, raw, truncated = _capture_shell(
1182
+ invocation,
1183
+ cwd=cwd,
1184
+ timeout=timeout,
1185
+ output_limit=output_limit,
1186
+ use_shell=use_shell,
1187
+ env=_sandbox_environment(),
1188
+ )
1189
+ except FileNotFoundError:
1190
+ return _shell_refusal(display, argv, f"command not found: {argv[0]!r}")
1191
+ except OSError as exc:
1192
+ return _shell_refusal(
1193
+ display, argv, f"could not start command: {type(exc).__name__}: {exc}"
1194
+ )
1195
+ duration_ms = int((time.monotonic() - started) * 1000)
1196
+ output = raw.decode("utf-8", errors="replace")[:output_limit]
1197
+ if len(raw) > output_limit:
1198
+ truncated = True
1199
+ return {
1200
+ "tool": SHELL_TOOL_NAME,
1201
+ "command": display,
1202
+ "argv": argv,
1203
+ "mode": policy.mode,
1204
+ "cwd": str(cwd),
1205
+ "executed": True,
1206
+ "exit_code": exit_code,
1207
+ "passed": exit_code == 0,
1208
+ "timed_out": timed_out,
1209
+ "duration_ms": duration_ms,
1210
+ "output": output,
1211
+ "output_truncated": truncated,
1212
+ "sandbox": True,
1213
+ "isolated": False,
1214
+ "backend": "local",
1215
+ "sandbox_root": str(root),
1216
+ "detail": (
1217
+ f"command timed out after {timeout:g}s"
1218
+ if timed_out
1219
+ else ("command passed" if exit_code == 0 else f"command exited {exit_code}")
1220
+ ),
1221
+ }
1222
+
1223
+
1224
+ def _propose_result(
1225
+ arguments: dict[str, Any],
1226
+ files: dict[str, str],
1227
+ proposed: dict[str, str],
1228
+ *,
1229
+ max_file_bytes: int = MAX_FILE_BYTES,
1230
+ max_files: int = MAX_FILES,
1231
+ max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
1232
+ ) -> dict[str, Any]:
1233
+ """Record a plan-mode file proposal. Same bounds as write_file, no mutation
1234
+ of the workspace: the proposal lives in ``proposed`` until the user
1235
+ approves it through the readback path."""
1236
+ try:
1237
+ path = _safe_path(arguments.get("path"))
1238
+ except NativeAgentError as exc:
1239
+ return {"error": str(exc)}
1240
+ content = arguments.get("content")
1241
+ if not isinstance(content, str):
1242
+ return {"error": "content must be a string"}
1243
+ if len(content.encode("utf-8")) > max_file_bytes:
1244
+ return {"error": f"file exceeds the {max_file_bytes}-byte limit"}
1245
+ known = (set(files) | set(proposed)) - {path}
1246
+ if path not in files and len(known) >= max_files:
1247
+ return {"error": f"workspace exceeds the {max_files}-file limit"}
1248
+ projected_size = sum(len(value.encode("utf-8")) for key, value in files.items() if key != path)
1249
+ projected_size += sum(
1250
+ len(value.encode("utf-8")) for key, value in proposed.items() if key != path
1251
+ )
1252
+ projected_size += len(content.encode("utf-8"))
1253
+ if projected_size > max_workspace_bytes:
1254
+ return {"error": f"workspace exceeds the {max_workspace_bytes}-byte limit"}
1255
+ proposed[path] = content
1256
+ return {"proposed": path, "bytes": len(content.encode("utf-8")), "written": False}
1257
+
1258
+
1259
+ def artifact_hash(files: dict[str, bytes]) -> str:
1260
+ """Stable digest over the tested tree: sorted unique relative paths +
1261
+ bytes. Same construction as the scanner's payload attestation
1262
+ (api/services/bc_build_upload.py payload_artifact_sha256) so the join is
1263
+ exact. Owned here — the loop is what produces ``tested_artifact_hash``; the
1264
+ server's ``agent_service`` re-exports it so there is one construction, and
1265
+ the module stays stdlib-only so it can be vendored verbatim into the local
1266
+ harness with no monorepo import."""
1267
+ unique: dict[str, bytes] = {}
1268
+ for rel, data in files.items():
1269
+ unique[rel.replace("\\", "/")] = data
1270
+ digest = hashlib.sha256()
1271
+ for rel in sorted(unique):
1272
+ digest.update(rel.encode("utf-8"))
1273
+ digest.update(b"\x00")
1274
+ digest.update(unique[rel])
1275
+ digest.update(b"\x00")
1276
+ return digest.hexdigest()
1277
+
1278
+
1279
+ def _diff(before: dict[str, str], after: dict[str, str]) -> str:
1280
+ chunks: list[str] = []
1281
+ for path in sorted(set(before) | set(after)):
1282
+ old = before.get(path, "")
1283
+ new = after.get(path, "")
1284
+ if old == new:
1285
+ continue
1286
+ chunks.extend(
1287
+ difflib.unified_diff(
1288
+ old.splitlines(True),
1289
+ new.splitlines(True),
1290
+ fromfile=f"a/{path}" if path in before else "/dev/null",
1291
+ tofile=f"b/{path}" if path in after else "/dev/null",
1292
+ )
1293
+ )
1294
+ return "".join(chunks)
1295
+
1296
+
1297
+ async def run_native_agent(
1298
+ *,
1299
+ task: str,
1300
+ workspace_files: dict[str, str],
1301
+ acceptance_criteria: list[str],
1302
+ out_of_scope: list[str],
1303
+ test_argv: list[str],
1304
+ complete: Complete,
1305
+ run_tests: RunTests,
1306
+ max_turns: int = MAX_TURNS,
1307
+ max_tool_calls: int = MAX_TOOL_CALLS,
1308
+ max_tool_output_chars: int = MAX_TOOL_OUTPUT_CHARS,
1309
+ max_files: int = MAX_FILES,
1310
+ max_workspace_bytes: int = MAX_WORKSPACE_BYTES,
1311
+ max_file_bytes: int = MAX_FILE_BYTES,
1312
+ should_stop: ShouldStop | None = None,
1313
+ tool_schemas: list[dict[str, Any]] | None = None,
1314
+ tool_dispatch: Dispatch | None = None,
1315
+ plan_only: bool = False,
1316
+ shell_policy: ShellPolicy | None = None,
1317
+ ) -> dict[str, Any]:
1318
+ """Run a bounded first-party tool loop and test only inside the sandbox.
1319
+
1320
+ SUB-58: the bounds are parameters defaulting to the module constants, which
1321
+ are kept and never removed. ``should_stop`` lets a caller end the loop
1322
+ between turns for a clean interrupt; the returned result then carries
1323
+ ``interrupted`` plus the partial trace, and the final approved test is not
1324
+ run (an interrupted run is never reported as tested or verified).
1325
+
1326
+ ``tool_schemas``/``tool_dispatch`` let a caller (the local harness) extend
1327
+ the tool set through ONE registry seam. When ``tool_schemas`` is given the
1328
+ loop advertises those schemas to the model; ``tool_dispatch`` then handles
1329
+ every call (base names must still route to ``_tool_result``). Both default to
1330
+ None, which keeps the server path on the base four-tool contract.
1331
+
1332
+ ``plan_only`` (SUB-81) runs the same loop READ-ONLY: ``write_file`` is never
1333
+ advertised or honored, ``propose_file_change`` records intentions, no tests
1334
+ run (nothing changed), and the result carries the model's step plan plus the
1335
+ diff/readback of the proposal. Nothing is written anywhere; approval happens
1336
+ later through the caller's existing readback path.
1337
+
1338
+ ``shell_policy`` is the ONE opt-in seam for the fifth tool, ``run_shell``.
1339
+ With no policy (the server path) the tool is never advertised or honored.
1340
+ An approved policy advertises the schema and runs each request through
1341
+ ``run_shell_tool`` (allow/deny lists, argv-or-shell mode, sandbox root,
1342
+ bounded timeout/output). Every request — executed or refused — is recorded
1343
+ in the result's ``shell`` list with its command and exit code, which is what
1344
+ the run receipt reports.
1345
+ """
1346
+ files = _validate_workspace(
1347
+ dict(workspace_files),
1348
+ max_files=max_files,
1349
+ max_workspace_bytes=max_workspace_bytes,
1350
+ max_file_bytes=max_file_bytes,
1351
+ )
1352
+ # SUB-94: load project policy from the workspace the loop is about to edit.
1353
+ # This is the ONE loader every run path reaches (CLI, bridge, Studio chat,
1354
+ # workbench), so precedence cannot drift between surfaces.
1355
+ project_rules = load_project_rules(files)
1356
+ config_plan = project_rules["config"].get("plan")
1357
+ if config_plan is not None and not isinstance(config_plan, bool):
1358
+ raise NativeAgentInputError(
1359
+ "project policy .sid/config field 'plan' must be true or false"
1360
+ )
1361
+ if config_plan:
1362
+ # Project policy can force the one loop read-only on every surface; an
1363
+ # explicit caller plan flag can never loosen it (precedence is additive).
1364
+ plan_only = True
1365
+ original = dict(files)
1366
+ base_tools = tool_schemas if tool_schemas is not None else TOOLS
1367
+ if plan_only:
1368
+ base_tools = [
1369
+ schema
1370
+ for schema in base_tools
1371
+ if (schema.get("function") or {}).get("name") not in MUTATING_TOOL_NAMES
1372
+ ]
1373
+ if not any((s.get("function") or {}).get("name") == "propose_file_change" for s in base_tools):
1374
+ base_tools = [*base_tools, PROPOSE_TOOL]
1375
+ elif shell_policy is not None and shell_policy.approved:
1376
+ # Explicit approval gate: the fifth tool is advertised only for a caller
1377
+ # that approved it. Plan mode never gets it (read-only run).
1378
+ if not any((s.get("function") or {}).get("name") == SHELL_TOOL_NAME for s in base_tools):
1379
+ base_tools = [*base_tools, SHELL_TOOL]
1380
+ active_tools = base_tools
1381
+ criteria = "\n".join(f"- {criterion}" for criterion in acceptance_criteria)
1382
+ exclusions = "\n".join(f"- {item}" for item in out_of_scope) or "- None stated"
1383
+ posture = (
1384
+ "You are SID, working inside a first-party, bounded coding workstation. "
1385
+ "Use only the supplied project tools. The user's approved criteria are "
1386
+ "binding; do not broaden scope. Inspect before editing, make the smallest "
1387
+ "change, run the approved tests, and report honestly. You cannot publish. "
1388
+ "Do not read secrets, private keys, dependency directories, or VCS data. "
1389
+ "Finish only after tests pass or explain the exact blocker."
1390
+ )
1391
+ system_parts = [posture, WORKSPACE_GUARDRAIL]
1392
+ if project_rules["instructions"]:
1393
+ system_parts.append(
1394
+ "PROJECT RULES — binding project policy loaded from the workspace. "
1395
+ "Apply every rule to what you inspect, edit, and run. If a rule "
1396
+ "cannot be satisfied, name that as a blocker; never silently ignore "
1397
+ "project policy.\n\n"
1398
+ f"Precedence: {project_rules['precedence']}\n\n"
1399
+ + project_rules["instructions"]
1400
+ )
1401
+ if plan_only:
1402
+ system_parts.append(PLAN_MODE_SYSTEM)
1403
+ elif shell_policy is not None and shell_policy.approved:
1404
+ system_parts.append(
1405
+ "SHELL — the user explicitly approved run_shell for this task. Use one "
1406
+ "command at a time, stay inside the sandbox root, and never try to "
1407
+ "escape it or chain commands. Refusals are policy, not failures to route "
1408
+ "around: report the blocker instead."
1409
+ )
1410
+ system_parts.extend(
1411
+ [
1412
+ f"APPROVED ACCEPTANCE CRITERIA:\n{criteria}\n\nOUT OF SCOPE:\n{exclusions}",
1413
+ f"APPROVED TEST ARGV: {json.dumps(test_argv)}",
1414
+ ]
1415
+ )
1416
+ system = "\n\n".join(system_parts)
1417
+ messages: list[dict[str, Any]] = [
1418
+ {"role": "system", "content": system},
1419
+ {
1420
+ "role": "user",
1421
+ "content": (
1422
+ f"Task: {task.strip()}\nWorkspace files: {len(files)}\n"
1423
+ + (
1424
+ "Inspect with list_files/read_file, propose edits with "
1425
+ "propose_file_change (nothing is written), and finish with a "
1426
+ "numbered step plan."
1427
+ if plan_only
1428
+ else "Use list_files/read_file/grep/glob to inspect relevant code, "
1429
+ "apply_patch (preferred) or write_file to edit, and "
1430
+ "run_approved_tests to run the approved command. delete_file and "
1431
+ "move_file require confirm=true."
1432
+ )
1433
+ ),
1434
+ },
1435
+ ]
1436
+ final_response = ""
1437
+ last_test: dict[str, Any] | None = None
1438
+ route_calls: list[dict[str, Any]] = []
1439
+ proposed: dict[str, str] = {}
1440
+ # Every run_shell request (executed or refused), in call order. The receipt
1441
+ # reports the command and exit code from here.
1442
+ shell_runs: list[dict[str, Any]] = []
1443
+ patches: list[dict[str, Any]] = []
1444
+ # Per-turn trace: what the model actually did (tool names, arg sizes,
1445
+ # finish reason) so a run receipt explains its own loop behavior — the
1446
+ # batch-1 debugging needed this and the receipt had nothing to show.
1447
+ turn_trace: list[dict[str, Any]] = []
1448
+ tool_calls_seen = 0
1449
+ interrupted = False
1450
+
1451
+ for step in range(1, max_turns + 1):
1452
+ if should_stop is not None and should_stop(step, tool_calls_seen):
1453
+ interrupted = True
1454
+ final_response = (
1455
+ f"Agent interrupted by the caller at step {step - 1} "
1456
+ f"({len(turn_trace)} turn(s) completed)."
1457
+ )
1458
+ break
1459
+ response = await complete(messages, active_tools)
1460
+ route_calls.append({
1461
+ "model": response.get("model"),
1462
+ "routing": response.get("routing"),
1463
+ })
1464
+ try:
1465
+ message = response["choices"][0]["message"]
1466
+ except (KeyError, IndexError, TypeError) as exc:
1467
+ raise NativeAgentError("SID model returned an invalid tool-loop response") from exc
1468
+ calls = message.get("tool_calls") or []
1469
+ content = message.get("content") or ""
1470
+ if not isinstance(content, str):
1471
+ content = str(content)
1472
+ turn_trace.append({
1473
+ "step": step,
1474
+ "finish_reason": response["choices"][0].get("finish_reason"),
1475
+ "content_chars": len(content),
1476
+ "tool_calls": [str((c.get("function") or {}).get("name") or "?") for c in calls],
1477
+ "usage": response.get("usage") or {},
1478
+ })
1479
+ if not calls:
1480
+ final_response = content
1481
+ break
1482
+ # Thinking-mode upstreams (deepseek) require the assistant turn's
1483
+ # reasoning_content to be passed back on subsequent turns; dropping it
1484
+ # hard-400s every continuation after the first tool call ("The
1485
+ # reasoning_content in the thinking mode must be passed back to the
1486
+ # API"). Carry it verbatim through the loop's history.
1487
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": content or None, "tool_calls": calls}
1488
+ reasoning = message.get("reasoning_content")
1489
+ if isinstance(reasoning, str) and reasoning:
1490
+ assistant_msg["reasoning_content"] = reasoning
1491
+ messages.append(assistant_msg)
1492
+ for call in calls:
1493
+ tool_calls_seen += 1
1494
+ if tool_calls_seen > max_tool_calls:
1495
+ raise NativeAgentError(f"agent exceeded the {max_tool_calls}-tool-call limit")
1496
+ call_id = str(call.get("id") or f"sid_tool_{tool_calls_seen}")
1497
+ function = call.get("function") or {}
1498
+ name = str(function.get("name") or "")
1499
+ try:
1500
+ arguments = json.loads(function.get("arguments") or "{}")
1501
+ if not isinstance(arguments, dict):
1502
+ raise ValueError("tool arguments must be an object")
1503
+ if plan_only and name in MUTATING_TOOL_NAMES:
1504
+ # Hard zero-write floor: even a model that hallucinates a
1505
+ # disabled mutating tool cannot mutate the workspace in plan
1506
+ # mode.
1507
+ result = {
1508
+ "error": (
1509
+ "plan mode is read-only: "
1510
+ f"{name} is disabled. Submit the intended edit with "
1511
+ "propose_file_change."
1512
+ ),
1513
+ "written": False,
1514
+ }
1515
+ elif plan_only and name == "propose_file_change":
1516
+ result = _propose_result(
1517
+ arguments,
1518
+ files,
1519
+ proposed,
1520
+ max_file_bytes=max_file_bytes,
1521
+ max_files=max_files,
1522
+ max_workspace_bytes=max_workspace_bytes,
1523
+ )
1524
+ elif plan_only and name == "run_approved_tests":
1525
+ result = {
1526
+ "skipped": True,
1527
+ "reason": (
1528
+ "plan mode is read-only: nothing is written, so no tests "
1529
+ "run. Name the verification step in the plan."
1530
+ ),
1531
+ }
1532
+ elif plan_only and name == SHELL_TOOL_NAME:
1533
+ result = {
1534
+ "tool": SHELL_TOOL_NAME,
1535
+ "executed": False,
1536
+ "exit_code": None,
1537
+ "skipped": True,
1538
+ "reason": "plan mode is read-only: shell is not available",
1539
+ }
1540
+ elif name == SHELL_TOOL_NAME:
1541
+ # The fifth tool is owned by the loop, not the extension
1542
+ # registry: an approved ShellPolicy is the one way in, and
1543
+ # the policy refuses every request when it is absent.
1544
+ result = run_shell_tool(arguments, shell_policy)
1545
+ elif tool_dispatch is not None:
1546
+ # A caller-supplied registry routes EVERY call (base and
1547
+ # extension) through one seam; base names still land in
1548
+ # _tool_result via the registry, so the contract keeps one
1549
+ # owner and there is no second engine.
1550
+ result = tool_dispatch(name, arguments, files, run_tests)
1551
+ else:
1552
+ result = _tool_result(
1553
+ name,
1554
+ arguments,
1555
+ files,
1556
+ run_tests,
1557
+ max_tool_output_chars=max_tool_output_chars,
1558
+ max_file_bytes=max_file_bytes,
1559
+ max_files=max_files,
1560
+ max_workspace_bytes=max_workspace_bytes,
1561
+ )
1562
+ if hasattr(result, "__await__"):
1563
+ result = await result
1564
+ if name == "run_approved_tests":
1565
+ last_test = result
1566
+ elif name == SHELL_TOOL_NAME:
1567
+ shell_runs.append(result)
1568
+ if (
1569
+ name == "apply_patch"
1570
+ and isinstance(result, dict)
1571
+ and result.get("diff")
1572
+ ):
1573
+ patches.append(
1574
+ {
1575
+ "path": result.get("patched"),
1576
+ "edits": result.get("edits"),
1577
+ "diff": result["diff"],
1578
+ }
1579
+ )
1580
+ except Exception as exc:
1581
+ result = {"error": f"tool failed: {type(exc).__name__}: {exc}"}
1582
+ payload = json.dumps(result, ensure_ascii=False, default=str)
1583
+ messages.append({"role": "tool", "tool_call_id": call_id, "content": payload[:max_tool_output_chars]})
1584
+ else:
1585
+ final_response = "Agent stopped at the bounded turn limit before returning a final answer."
1586
+
1587
+ # Always run the exact approved test command once on the final file set,
1588
+ # even if the model forgot or ran tests before its last edit. A clean
1589
+ # interrupt (SUB-58) skips this: the caller stopped the run, so it is not
1590
+ # tested and never reported as tested or verified. Plan mode (SUB-81) has
1591
+ # nothing to test: it never wrote the workspace.
1592
+ if not interrupted and not plan_only:
1593
+ final_test = await run_tests(files)
1594
+ last_test = final_test
1595
+ elif plan_only:
1596
+ last_test = {
1597
+ "backend": None,
1598
+ "isolated": False,
1599
+ "exit_code": None,
1600
+ "passed": False,
1601
+ "skipped": True,
1602
+ "reason": "plan mode is read-only: nothing was written, so no tests were run",
1603
+ "stdout": "",
1604
+ "stderr": "",
1605
+ }
1606
+ if plan_only:
1607
+ proposed = {path: content for path, content in proposed.items() if files.get(path) != content}
1608
+ changes = sorted(proposed)
1609
+ readback = {path: proposed[path] for path in changes}
1610
+ diff_text = _diff(original, {**files, **proposed})
1611
+ else:
1612
+ changes = sorted(path for path in set(original) | set(files) if original.get(path) != files.get(path))
1613
+ readback = {path: files[path] for path in changes if path in files}
1614
+ diff_text = _diff(original, files)
1615
+ artifact_files = {path: content.encode("utf-8") for path, content in files.items()}
1616
+ return {
1617
+ "final_response": final_response,
1618
+ "project_rules": project_rules,
1619
+ "plan": final_response if plan_only else None,
1620
+ "plan_mode": plan_only,
1621
+ "writes": 0 if plan_only else len(changes),
1622
+ "test": last_test,
1623
+ "tests": last_test,
1624
+ "readback": readback,
1625
+ "artifact_files": artifact_files,
1626
+ "tested_artifact_hash": artifact_hash(artifact_files),
1627
+ "diff": diff_text or None,
1628
+ "patches": patches,
1629
+ "changed": changes,
1630
+ "isolated": bool(last_test and last_test.get("isolated")),
1631
+ "backend": last_test.get("backend") if last_test else None,
1632
+ "model": route_calls[-1]["model"] if route_calls else "auto",
1633
+ "route_calls": route_calls,
1634
+ "turn_trace": turn_trace,
1635
+ "shell": shell_runs,
1636
+ "steps": step if "step" in locals() else 0,
1637
+ "tool_calls": tool_calls_seen,
1638
+ "interrupted": interrupted,
1639
+ }