devlyn-cli 4.3.17 → 4.3.21

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 (39) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +19 -0
  4. package/bin/instruction-templates.json +120 -0
  5. package/bin/skill-history.json +11 -0
  6. package/config/skills/_shared/adapters/claude.md +15 -0
  7. package/config/skills/_shared/adapters/codex.md +10 -0
  8. package/config/skills/_shared/invocation-receipt.py +179 -9
  9. package/config/skills/_shared/platform-support.py +102 -1
  10. package/config/skills/_shared/runtime-principles.md +1 -1
  11. package/config/skills/_shared/unattended-tools.py +206 -0
  12. package/config/skills/devlyn-ideate/SKILL.md +1 -1
  13. package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  14. package/config/skills/devlyn-ideate/scripts/queue.py +24 -11
  15. package/harness/AGENTS.md +2 -2
  16. package/harness/CLAUDE.md +2 -2
  17. package/native/claude/.claude-plugin/plugin.json +1 -1
  18. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  19. package/native/claude/skills/_shared/adapters/codex.md +10 -0
  20. package/native/claude/skills/_shared/invocation-receipt.py +179 -9
  21. package/native/claude/skills/_shared/platform-support.py +102 -1
  22. package/native/claude/skills/_shared/runtime-principles.md +1 -1
  23. package/native/claude/skills/_shared/unattended-tools.py +206 -0
  24. package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
  25. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  26. package/native/claude/skills/devlyn-ideate/scripts/queue.py +24 -11
  27. package/native/codex/.agents/plugins/marketplace.json +1 -1
  28. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  29. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  30. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
  31. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
  32. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
  33. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
  34. package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
  35. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
  36. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  37. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +24 -11
  38. package/package.json +1 -1
  39. package/release.json +53 -38
@@ -92,13 +92,16 @@ def file_prompt_args(argv):
92
92
  raise TransportError("file transport requires the sole prompt argument '-'; competing prompts are forbidden")
93
93
 
94
94
 
95
- def prepare_transport(prompt_path, command, argv, seconds):
96
- path = pathlib.Path(prompt_path).resolve(strict=True)
97
- with PLATFORM["open_stdin"](path) as source:
98
- raw = source.read()
95
+ def prepare_transport(prompt_path, seconds):
96
+ try:
97
+ path = pathlib.Path(prompt_path).resolve(strict=True)
98
+ with PLATFORM["open_stdin"](path) as source:
99
+ raw = source.read()
100
+ except (OSError, ValueError) as exc:
101
+ raise TransportError(f"DEVLYN_CODEX_PROMPT_FILE is not a readable prompt file: {prompt_path}") from exc
99
102
  record = {"schema_version": 1, "transport": "stdin-file",
100
103
  "prompt": {"path": str(path), "sha256": sha256(raw), "bytes": len(raw)},
101
- "command": command, "argv": argv, "timeout_sec": seconds,
104
+ "timeout_sec": seconds,
102
105
  "status": "started", "exit_code": None}
103
106
  carrier = path.with_name(path.name + ".transport.json")
104
107
  if carrier.exists():
@@ -157,6 +160,7 @@ def monitor_descendant_regression() -> None:
157
160
  fake_codex = work / "fake-codex"
158
161
  fake_codex.write_text(
159
162
  f"#!{real_bash}\n"
163
+ "case \" $* \" in *' features list '*) for f in apps plugins remote_plugin computer_use browser_use browser_use_external in_app_browser; do printf '%s stable false\\n' \"$f\"; done; exit 0;; *' mcp list --json '*) printf '[]\\n'; exit 0;; esac\n"
160
164
  "printf '%s\\n' \"$$\" > \"$DEVLYN_WATCHDOG_TEST_CODEX_PID\"\n"
161
165
  "while [ ! -e \"$DEVLYN_WATCHDOG_TEST_RELEASE\" ]; do\n"
162
166
  " \"$DEVLYN_WATCHDOG_TEST_REAL_SLEEP\" 0.01\n"
@@ -184,6 +188,7 @@ def monitor_descendant_regression() -> None:
184
188
  ):
185
189
  env.pop(key)
186
190
  env.update({
191
+ "HOME": str(work), "USERPROFILE": str(work),
187
192
  "CODEX_BIN": str(fake_codex),
188
193
  "CODEX_MONITORED_HEARTBEAT": "1",
189
194
  "CODEX_MONITORED_TIMEOUT_SEC": "30",
@@ -411,11 +416,173 @@ def monitor_descendant_regression() -> None:
411
416
  raise
412
417
 
413
418
 
419
+ def unattended_tools_regression():
420
+ with tempfile.TemporaryDirectory() as raw_tmp:
421
+ work = pathlib.Path(raw_tmp).resolve()
422
+ fake = work / "codex"
423
+ fake.write_text(f"#!{sys.executable}\n" + '''
424
+ import json, os, pathlib, re, sys
425
+ a = sys.argv[1:]
426
+ with open("probes.jsonl", "a") as stream:
427
+ stream.write(json.dumps({"argv": a, "cwd": os.getcwd()}) + "\\n")
428
+ features = "apps plugins remote_plugin computer_use browser_use browser_use_external in_app_browser".split()
429
+ config, disabled = {}, set()
430
+ i = 0
431
+ while i < len(a):
432
+ token = a[i]
433
+ if token == "--": break
434
+ if token.startswith("-c") and not token.startswith("--") and len(token) > 2:
435
+ key, value = "-c", token[2:].removeprefix("=")
436
+ else:
437
+ key, equal, value = token.partition("=")
438
+ if key in ("-c", "--config", "--disable", "--enable", "-p", "--profile", "-C", "--cd") and not equal:
439
+ i += 1
440
+ value = a[i]
441
+ if key in ("-c", "--config"):
442
+ name, sep, val = value.partition("=")
443
+ if not sep: sys.exit("missing config value")
444
+ if name.startswith('mcp_servers."'): sys.exit("invalid transport in " + name)
445
+ config[name] = val
446
+ if key == "--disable": disabled.add(value)
447
+ i += 1
448
+ if a == ["--version"]:
449
+ print("codex-fixture 1.2.3")
450
+ elif a[:2] == ["features", "list"]:
451
+ if os.environ.get("FEATURE_FAIL"):
452
+ sys.exit("Unknown feature flag: in_app_browser")
453
+ for f in features:
454
+ mode = os.environ.get("FEATURE_MODE") if f == "apps" else None
455
+ if mode == "missing": continue
456
+ print(f, "under development", "garbled" if mode == "malformed" else str(mode == "sticky" or f not in disabled).lower())
457
+ elif "mcp" in a:
458
+ assert a[a.index("mcp"):a.index("mcp") + 3] == ["mcp", "list", "--json"], a
459
+ verify = any(k.endswith(".enabled") for k in config)
460
+ if os.environ.get("LIST_FAIL") or (verify and os.environ.get("VERIFY_FAIL")):
461
+ sys.exit("cannot list servers")
462
+ raw = os.environ.get("VERIFY_SERVERS" if verify else "SERVERS", os.environ.get("SERVERS", '[{"name":"node_repl","enabled":true},{"name":"pyx-memory","enabled":true}]'))
463
+ try: servers = json.loads(raw)
464
+ except ValueError:
465
+ print(raw); sys.exit(0)
466
+ if isinstance(servers, list) and all(isinstance(s, dict) and "name" in s and isinstance(s.get("enabled"), bool) for s in servers):
467
+ servers = {s["name"]: s for s in servers}
468
+ for key in config:
469
+ match = re.fullmatch(r"mcp_servers\\.([A-Za-z0-9_-]+)\\.command", key)
470
+ if match:
471
+ name = match[1]
472
+ servers.setdefault(name, {"name": name, "enabled": True})
473
+ for key, val in config.items():
474
+ match = re.fullmatch(r"mcp_servers\\.([A-Za-z0-9_-]+)\\.(command|enabled)", key)
475
+ if match:
476
+ name, field = match.groups()
477
+ if field == "command": servers.setdefault(name, {"name": name, "enabled": True})
478
+ elif not os.environ.get("MCP_STICKY"):
479
+ if name not in servers: sys.exit("invalid transport")
480
+ servers[name]["enabled"] = val == "true"
481
+ servers = list(servers.values())
482
+ print(json.dumps(servers))
483
+ else:
484
+ assert a[0] == "exec" and a[1:15] == [v for f in features for v in ("--disable", f)], a
485
+ pathlib.Path("seen.json").write_text(json.dumps(a))
486
+ pathlib.Path("stdin").write_bytes(sys.stdin.buffer.read())
487
+ sys.exit(int(os.environ.get("EXEC_CODE", "0")))
488
+ ''', encoding="utf-8")
489
+ fake.chmod(0o755)
490
+ wrapper = pathlib.Path(__file__).resolve().with_name("codex-monitored.sh")
491
+ env = {k: v for k, v in os.environ.items() if not k.startswith("CODEX_") and k != "DEVLYN_CODEX_PROMPT_FILE"}
492
+ env.update(HOME=str(work), USERPROFILE=str(work), CODEX_BIN=str(fake))
493
+ seen = work / "seen.json"
494
+ deny = work / ".devlyn/unattended-tools.json"
495
+ deny.parent.mkdir()
496
+
497
+ def launch(extra=None, code=0, args=None):
498
+ seen.unlink(missing_ok=True)
499
+ with (work / "stdout").open("wb") as stdout, (work / "stderr").open("wb") as stderr:
500
+ result = subprocess.run(["bash", str(wrapper), *(args or ["hello"])], cwd=work,
501
+ env={**env, **(extra or {})}, stdout=stdout, stderr=stderr, timeout=60)
502
+ error = (work / "stderr").read_text(encoding="utf-8")
503
+ assert result.returncode == code, (result.returncode, error)
504
+ if code == 2:
505
+ assert not seen.exists(), error
506
+ return error
507
+
508
+ launch(code=7, extra={"EXEC_CODE": "7"})
509
+ argv = json.loads(seen.read_text())
510
+ shutdown = [v for f in ("apps", "plugins", "remote_plugin", "computer_use", "browser_use",
511
+ "browser_use_external", "in_app_browser") for v in ("--disable", f)]
512
+ assert argv == ["exec", *shutdown, "-c", "mcp_servers.node_repl.enabled=false", "hello"], argv
513
+ assert not any("computer-use" in a or "pyx-memory" in a for a in argv)
514
+ assert (work / "stdin").read_bytes() == b""
515
+
516
+ name = "desktop-custom"
517
+ deny.write_text(json.dumps({"codex": [name, "absent"]}), encoding="utf-8")
518
+ discovery = work / "project"
519
+ discovery.mkdir()
520
+ prompt = work / "prompt.md"
521
+ prompt.write_bytes(b"exact prompt\nsecond line\n")
522
+ launch(extra={"CODEX_BIN": "./codex", "SERVERS": json.dumps([{"name": name, "enabled": True}]),
523
+ "DEVLYN_CODEX_PROMPT_FILE": str(prompt)},
524
+ args=["-C", str(discovery), "--config=model_reasoning_effort=high", "--", "-"])
525
+ argv = json.loads(seen.read_text())
526
+ assert f"mcp_servers.{name}.enabled=false" in argv, argv
527
+ assert not any("absent" in a or "node_repl" in a for a in argv)
528
+ assert argv[-2:] == ["--", "-"] and (work / "stdin").read_bytes() == prompt.read_bytes()
529
+ receipt = json.loads(prompt.with_name("prompt.md.transport.json").read_text())
530
+ assert receipt["command"] == ["./codex", *argv] and receipt["status"] == "completed"
531
+ probes = [json.loads(line) for line in (discovery / "probes.jsonl").read_text().splitlines()]
532
+ listing = [p for p in probes if "mcp" in p["argv"]][-1]
533
+ assert listing["cwd"] == str(discovery) and "model_reasoning_effort=high" in listing["argv"], listing
534
+
535
+ deny.write_text(json.dumps({"codex": ["desktop.quoted"]}), encoding="utf-8")
536
+ assert "desktop.quoted" in launch({"SERVERS": '[{"name":"desktop.quoted","enabled":true}]'}, code=2)
537
+ deny.unlink()
538
+ # Every accepted spelling reaches discovery, without moving or altering caller argv.
539
+ for option, value in (("-c", 'mcp_servers.node_repl.command="echo"'), ("--config", 'mcp_servers.node_repl.command="echo"'),
540
+ ("-p", "fixture"), ("--profile", "fixture"), ("--enable", "hooks"), ("--disable", "hooks"),
541
+ ("-C", str(discovery)), ("--cd", str(discovery))):
542
+ forms = [[option, value], [option + "=" + value]]
543
+ if option in {"-c", "-p", "-C"}:
544
+ forms.append([option + value])
545
+ for form in forms:
546
+ for log in (work / "probes.jsonl", discovery / "probes.jsonl"):
547
+ log.unlink(missing_ok=True)
548
+ launch(args=[*form, "--", "prompt"], extra={"SERVERS": '[]'})
549
+ actual = json.loads(seen.read_text())
550
+ assert actual[-len(form) - 2:] == [*form, "--", "prompt"], actual
551
+ directory = discovery if option in {"-C", "--cd"} else work
552
+ probes = [json.loads(line) for line in (directory / "probes.jsonl").read_text().splitlines()]
553
+ listings = [p for p in probes if "mcp" in p["argv"]]
554
+ assert len(listings) == 2, listings
555
+ for listing in listings:
556
+ assert listing["cwd"] == str(directory), listing
557
+ if option not in {"-C", "--cd"}:
558
+ assert [option, value] in [listing["argv"][i:i + 2] for i in range(len(listing["argv"]) - 1)], listing
559
+ if option in {"-c", "--config"}:
560
+ assert "mcp_servers.node_repl.enabled=false" in actual, actual
561
+
562
+ for mode in ("sticky", "missing", "malformed"):
563
+ assert "Codex feature" in launch({"FEATURE_MODE": mode}, code=2)
564
+ assert "overrides did not disable" in launch({"MCP_STICKY": "1"}, code=2)
565
+ assert "overrides did not disable" in launch(args=["-c", "mcp_servers.node_repl.enabled=true", "hello"], code=2)
566
+ assert "cannot list servers" in launch({"VERIFY_FAIL": "1"}, code=2)
567
+ assert "malformed Codex mcp list" in launch({"VERIFY_SERVERS": "not json"}, code=2)
568
+
569
+ for bad in ('{', '[]', '{"codex": "x"}', '{"claude": [1]}', '{"codex":[],"codex":[]}'):
570
+ deny.write_text(bad, encoding="utf-8")
571
+ assert str(deny) in launch(code=2)
572
+ deny.unlink()
573
+ assert "codex-fixture 1.2.3" in launch({"FEATURE_FAIL": "1"}, code=2)
574
+ assert "cannot list servers" in launch({"LIST_FAIL": "1"}, code=2)
575
+ for bad in ('not json', '{}', '[{}]', '[{"name":"node_repl","enabled":"true"}]'):
576
+ assert "malformed Codex mcp list" in launch({"SERVERS": bad}, code=2)
577
+ print("PASS unattended Codex defaults, discovery, receipts and fail-closed refusals")
578
+
579
+
414
580
  def self_test() -> int:
415
581
  monitor_descendant_regression()
416
582
  if os.name == "nt":
417
583
  print("SKIP POSIX executable-shell wrapper fixture; native npm transport is covered by test-windows-portability.py")
418
584
  return 0
585
+ unattended_tools_regression()
419
586
  with tempfile.TemporaryDirectory() as raw_tmp:
420
587
  work = pathlib.Path(raw_tmp)
421
588
  fake_codex = work / "fake-codex"
@@ -442,17 +609,20 @@ def complete_dispatch(exit_code):
442
609
 
443
610
  def dispatch_codex(args):
444
611
  argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
445
- command = [args.binary, "exec", *argv]
446
- actual = PLATFORM["native_argv"](command)
447
612
  prompt = os.environ.get("DEVLYN_CODEX_PROMPT_FILE")
448
613
  if prompt == "":
449
614
  raise TransportError("DEVLYN_CODEX_PROMPT_FILE must name a readable prompt file")
615
+ if prompt:
616
+ file_prompt_args(argv)
450
617
  stream, carrier, transport = None, None, None
451
618
  try:
452
619
  if prompt:
453
- file_prompt_args(argv)
454
- stream, carrier, transport = prepare_transport(prompt, command, actual, args.timeout)
620
+ stream, carrier, transport = prepare_transport(prompt, args.timeout)
621
+ argv = runpy.run_path(str(pathlib.Path(__file__).with_name("unattended-tools.py")))["codex_launch"](args.binary, argv)
622
+ command = [args.binary, "exec", *argv]
623
+ actual = PLATFORM["native_argv"](command)
455
624
  if transport is not None:
625
+ transport.update(command=command, argv=actual)
456
626
  write_transport(carrier, transport)
457
627
  if os.name != "nt":
458
628
  if stream is not None:
@@ -6,6 +6,7 @@ import contextlib
6
6
  import errno
7
7
  import os
8
8
  from pathlib import Path
9
+ import platform
9
10
  import re
10
11
  import shutil
11
12
  import signal
@@ -81,7 +82,14 @@ NPM_CLI = re.compile(r'SET "(NP[MX]_CLI_JS)=%~dp0\\([^"\r\n]+)"')
81
82
 
82
83
  def native_argv(argv, cwd=None):
83
84
  """Resolve argv as Windows does, for a command that will run in cwd; a Node command shim runs its Node and script
84
- directly, never cmd /c."""
85
+ directly, never cmd /c; on macOS avoid kernel #! exec, whose process tree keeps kernel memory for each later
86
+ #! exec until restart."""
87
+ if sys.platform == "darwin":
88
+ try:
89
+ prefix = darwin_script_argv(argv[0], cwd or os.getcwd())
90
+ return [*prefix, *argv[1:]] if prefix else list(argv)
91
+ except (OSError, ValueError, TypeError, NotImplementedError, IndexError):
92
+ return list(argv) # Preserve CPython's PATH search and errors without a shell fallback.
85
93
  if os.name != "nt":
86
94
  return list(argv)
87
95
  binary = shutil.which(argv[0])
@@ -100,6 +108,99 @@ def native_argv(argv, cwd=None):
100
108
  return [node, str(script.resolve(strict=True)), *argv[1:]]
101
109
 
102
110
 
111
+ def darwin_script_argv(program, cwd):
112
+ """The argv prefix macOS would build for a safely resolved script with a native interpreter, else None.
113
+
114
+ Search with CPython's os.get_exec_path(), including os.defpath when PATH is unset. A CPU-compatible
115
+ interpreter with an otherwise damaged image fails against the interpreter instead of retrying PATH.
116
+ The interpreter path is executed as it is at exec time: replacing it requires write access that
117
+ already lets the writer run code as the user. A set-id replacement owned by another user cannot
118
+ raise privilege; only root can create root-owned set-id files. Quarantined scripts are declined;
119
+ other exec-only MAC policies that permit reading but deny executing the script are not consulted.
120
+ """
121
+ cpu = {"arm64": 0x0100000c, "x86_64": 0x01000007}.get(platform.machine())
122
+ if cpu is None:
123
+ return None
124
+ subtypes = (0, 1, 2) if cpu == 0x0100000c else (3,)
125
+ entries = [None] if "/" in program else os.get_exec_path()
126
+ with contextlib.ExitStack() as handles:
127
+ try:
128
+ cwd_fd = os.open(cwd, os.O_RDONLY | os.O_DIRECTORY)
129
+ handles.callback(os.close, cwd_fd)
130
+ for entry in entries:
131
+ if entry is not None and len(os.fsencode(entry)) + len(os.fsencode(program)) + 2 > 1024:
132
+ return None
133
+ candidate = program if entry is None else os.path.join(entry, program)
134
+ try:
135
+ mode = os.stat(candidate, dir_fd=cwd_fd).st_mode
136
+ except (FileNotFoundError, NotADirectoryError):
137
+ continue
138
+ if not stat.S_ISREG(mode) or not os.access(candidate, os.X_OK, dir_fd=cwd_fd, effective_ids=True):
139
+ continue
140
+ with os.fdopen(os.open(candidate, os.O_RDONLY | os.O_NONBLOCK, dir_fd=cwd_fd), "rb") as stream:
141
+ mode = os.fstat(stream.fileno()).st_mode
142
+ if not stat.S_ISREG(mode) or mode & (stat.S_ISUID | stat.S_ISGID):
143
+ return None
144
+ try:
145
+ if hasattr(os, "getxattr"):
146
+ os.getxattr(stream.fileno(), "com.apple.quarantine")
147
+ else:
148
+ # CPython exposes os.getxattr on Linux, but macOS needs its native fd API.
149
+ import ctypes
150
+ getxattr = ctypes.CDLL(None, use_errno=True).fgetxattr
151
+ getxattr.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t,
152
+ ctypes.c_uint32, ctypes.c_int]
153
+ getxattr.restype = ctypes.c_ssize_t
154
+ if getxattr(stream.fileno(), b"com.apple.quarantine", None, 0, 0, 0) < 0:
155
+ error = ctypes.get_errno()
156
+ raise OSError(error, os.strerror(error))
157
+ except OSError as exc:
158
+ if exc.errno != getattr(errno, "ENOATTR", None):
159
+ return None
160
+ else:
161
+ return None
162
+ header = stream.read(512)
163
+ if not header.startswith(b"#!"):
164
+ return None
165
+ line = re.split(b"[#\n]", header[2:], maxsplit=1)
166
+ if len(line) != 2:
167
+ return None
168
+ words = re.split(b"[ \t]+", line[0].strip(b" \t"))
169
+ if b"\0" in line[0] or not words[0].startswith(b"/"):
170
+ return None
171
+ interpreter = os.fsdecode(words[0])
172
+ mode = os.stat(interpreter).st_mode
173
+ if (not stat.S_ISREG(mode) or mode & (stat.S_ISUID | stat.S_ISGID)
174
+ or not os.access(interpreter, os.X_OK, effective_ids=True)):
175
+ return None
176
+ with open_stdin(interpreter) as stream:
177
+ image = stream.read(512)
178
+ magic = int.from_bytes(image[:4], "big")
179
+ if magic not in (0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe,
180
+ 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca):
181
+ return None
182
+ order = "little" if magic in (0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca) else "big"
183
+ if magic in (0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe):
184
+ size = 32 if magic in (0xfeedfacf, 0xcffaedfe) else 28
185
+ if (len(image) < size or int.from_bytes(image[4:8], order) != cpu
186
+ or int.from_bytes(image[8:12], order) & ~0xff000000 not in subtypes):
187
+ return None
188
+ else:
189
+ count = int.from_bytes(image[4:8], order)
190
+ size = 32 if magic in (0xcafebabf, 0xbfbafeca) else 20
191
+ if len(image) < 8 or count > (len(image) - 8) // size:
192
+ return None
193
+ if not any(int.from_bytes(image[8 + i * size:12 + i * size], order) == cpu
194
+ and int.from_bytes(image[12 + i * size:16 + i * size], order) & ~0xff000000 in subtypes
195
+ for i in range(count)):
196
+ return None
197
+ return [*map(os.fsdecode, words), candidate]
198
+ except (OSError, ValueError, TypeError, NotImplementedError):
199
+ # Preserve the original exec, including PATH retries, whenever inspection is uncertain.
200
+ return None
201
+ return None
202
+
203
+
103
204
  def shim_script(shim, raw, node, cwd=None):
104
205
  """The script a Node command shim runs, as cmd.exe would resolve it, else None: the one target of an npm, pnpm
105
206
  or corepack cmd-shim that sets no environment, or for Node.js's own npm.cmd and npx.cmd the global prefix's
@@ -13,7 +13,7 @@ This contract serves one goal: any capable engine — Claude, GPT/Codex, or a fu
13
13
  Seven rules govern every change. Cite them by name when a decision touches one.
14
14
 
15
15
  1. **No workaround** — fix the root cause, never the symptom. No `any`, no `@ts-ignore`, no silent `catch`, no hardcoded fallback that hides a broken contract. No config bypasses.
16
- 2. **No overengineering** — smallest change that closes the goal. New abstractions require an observed failure mode they prevent. Subtractive-first: ask "what can I delete instead?" before writing anything new. Remove code your change makes unused; only report unrelated pre-existing dead code.
16
+ 2. **No overengineering** — smallest sufficient change, one responsibility per module. New abstractions need an observed failure. Subtractive-first: ask "what can I delete instead?" before writing anything new. Remove code your change makes unused; only report unrelated pre-existing dead code.
17
17
  3. **No guesswork** — verify with the actual files, logs, diffs, and run output before forming conclusions. State the falsifiable prediction BEFORE the experiment; record raw results AFTER. Retroactive prediction edits are dishonest.
18
18
  4. **Worldclass** — code that survives review at a non-trivial codebase. Zero CRITICAL, zero HIGH security/design findings on the shippable path.
19
19
  5. **Best practice** — idiomatic for the language and framework. Use standard primitives; do not hand-roll what the library already provides.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Launch-only tool restrictions for delegated Codex and drain Claude children."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import runpy
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+
15
+
16
+ PLATFORM = runpy.run_path(str(Path(__file__).with_name("platform-support.py")))
17
+ LOADS = runpy.run_path(str(Path(__file__).with_name("expected-contract.py")))["loads_strict_json"]
18
+ CODEX_FEATURES = ("apps", "plugins", "remote_plugin", "computer_use", "browser_use",
19
+ "browser_use_external", "in_app_browser")
20
+
21
+
22
+ class PolicyError(ValueError):
23
+ pass
24
+
25
+
26
+ def deny_names(engine):
27
+ path = Path.home() / ".devlyn/unattended-tools.json"
28
+ try:
29
+ raw = path.read_text(encoding="utf-8")
30
+ except FileNotFoundError:
31
+ return []
32
+ except (OSError, UnicodeError) as exc:
33
+ raise PolicyError(f"unattended tools: cannot read {path}: {exc}") from exc
34
+ try:
35
+ value = LOADS(raw)
36
+ if not isinstance(value, dict) or set(value) - {"codex", "claude"}:
37
+ raise ValueError("expected an object with codex/claude server-name arrays")
38
+ for names in value.values():
39
+ if not isinstance(names, list) or any(not isinstance(n, str) or not n.strip() for n in names):
40
+ raise ValueError("codex/claude must be arrays of nonempty server names")
41
+ return value.get(engine, [])
42
+ except ValueError as exc:
43
+ raise PolicyError(f"unattended tools: invalid {path}: {exc}") from exc
44
+
45
+
46
+ def probe(command, cwd, label, env=None):
47
+ try:
48
+ result = subprocess.run(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
49
+ capture_output=True, text=True, encoding="utf-8", timeout=30)
50
+ except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc:
51
+ raise PolicyError(f"unattended tools: {label} failed: {exc}") from exc
52
+ if result.returncode:
53
+ raise PolicyError(f"unattended tools: {label} exited {result.returncode}: {result.stderr.strip()}")
54
+ return result.stdout
55
+
56
+
57
+ def listed(command, cwd, label, key, env=None):
58
+ raw = probe(command, cwd, label, env)
59
+ try:
60
+ value = LOADS(raw)
61
+ if not isinstance(value, list) or any(
62
+ not isinstance(item, dict) or not isinstance(item.get(key), str) or not item[key].strip()
63
+ or not isinstance(item.get("enabled"), bool) for item in value
64
+ ):
65
+ raise ValueError(f"expected an array of objects with {key} and boolean enabled")
66
+ return value
67
+ except ValueError as exc:
68
+ raise PolicyError(f"unattended tools: malformed {label} output: {exc}") from exc
69
+
70
+
71
+ def codex_launch(binary, argv):
72
+ denied = {"node_repl", "computer-use", *deny_names("codex")}
73
+ resolved = shutil.which(binary)
74
+ if resolved is None:
75
+ raise PolicyError(f"unattended tools: Codex executable unavailable: {binary}")
76
+ native = PLATFORM["native_argv"]([str(Path(resolved).absolute())])
77
+ cwd, config, profile = Path.cwd(), [], []
78
+ # Only configuration inputs belong in discovery; prompt/model/output arguments do not.
79
+ values = {"-c", "--config", "-p", "--profile", "--enable", "--disable", "-C", "--cd",
80
+ "-m", "--model", "-s", "--sandbox", "-o", "--output-last-message", "--output-schema",
81
+ "--color", "--add-dir", "-i", "--image", "--local-provider", "--thread-source",
82
+ "--cyber-access-program"}
83
+ index = 0
84
+ while index < len(argv) and argv[index] != "--":
85
+ token = argv[index]
86
+ if token[:2] in {"-c", "-C", "-p"} and len(token) > 2:
87
+ key, equal, value = token[:2], "=", token[2:]
88
+ if value.startswith("="):
89
+ value = value[1:]
90
+ else:
91
+ key, equal, value = token.partition("=")
92
+ if key in values:
93
+ if not equal:
94
+ index += 1
95
+ if index == len(argv):
96
+ raise PolicyError(f"unattended tools: {key} requires a value")
97
+ value = argv[index]
98
+ if key in {"-C", "--cd"}:
99
+ cwd = Path(value).absolute()
100
+ elif key in {"-p", "--profile"}:
101
+ profile.extend([key, value])
102
+ elif key in {"-c", "--config", "--enable", "--disable"}:
103
+ config.extend([key, value])
104
+ elif token in {"--ignore-user-config", "--strict-config"}:
105
+ config.append(token)
106
+ index += 1
107
+ shutdown = [part for feature in CODEX_FEATURES for part in ("--disable", feature)]
108
+ try:
109
+ # Codex 0.164 accepts profiles on mcp, but not on features list.
110
+ raw = probe([*native, "features", "list", *shutdown, *config], cwd, "Codex feature preflight")
111
+ states = {}
112
+ for row in raw.splitlines():
113
+ fields = row.split()
114
+ if fields and fields[0] in CODEX_FEATURES:
115
+ if len(fields) < 3 or fields[-1] not in {"true", "false"} or fields[0] in states:
116
+ raise PolicyError(f"unattended tools: malformed Codex feature row: {row}")
117
+ states[fields[0]] = fields[-1]
118
+ unsafe = [name for name in CODEX_FEATURES if states.get(name) != "false"]
119
+ if unsafe:
120
+ raise PolicyError(f"unattended tools: Codex features not disabled: {', '.join(unsafe)}")
121
+ except PolicyError as exc:
122
+ version = probe([*native, "--version"], cwd, "Codex version").strip()
123
+ raise PolicyError(f"{exc} (Codex version: {version})") from exc
124
+ command = [*native, *profile, "mcp", "list", "--json"]
125
+ servers = listed([*command, *shutdown, *config], cwd, "Codex mcp list --json", "name")
126
+ targeted = {s["name"] for s in servers} & denied
127
+ overrides = []
128
+ for name in sorted(targeted):
129
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
130
+ raise PolicyError(f"unattended tools: cannot safely override Codex server: {name!r}")
131
+ overrides.extend(["-c", f"mcp_servers.{name}.enabled=false"])
132
+ # Match launch order: caller overrides come after the policy. Refuse a conflict.
133
+ verification = listed([*command, *shutdown, *overrides, *config], cwd,
134
+ "Codex mcp list --json verification", "name")
135
+ disabled = {s["name"] for s in verification if not s["enabled"]}
136
+ unsafe = (targeted - disabled) | {s["name"] for s in verification if s["enabled"] and s["name"] in denied}
137
+ if unsafe:
138
+ raise PolicyError(f"unattended tools: Codex MCP overrides did not disable: {', '.join(sorted(unsafe))}")
139
+ return [*shutdown, *overrides, *argv]
140
+
141
+
142
+ def claude_namespace(name):
143
+ # Claude Code's MCP tool-name normalization preserves ASCII letters, digits, '_' and '-'.
144
+ normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
145
+ if name.startswith("claude.ai "):
146
+ normalized = re.sub(r"_+", "_", normalized).strip("_")
147
+ return normalized
148
+
149
+
150
+ @contextlib.contextmanager
151
+ def claude_launch(argv, cwd):
152
+ denied = deny_names("claude")
153
+ native = PLATFORM["native_argv"]([argv[0]], cwd=cwd)
154
+ # Refuse configuration inputs we cannot verify. Do not consume or rewrite prompt values.
155
+ for index, token in enumerate(argv[1:], 1):
156
+ key, equal, value = token.partition("=")
157
+ if key in {"--settings", "--setting-sources", "--plugin-dir", "--plugin-url", "--chrome"}:
158
+ raise PolicyError(f"unattended tools: Claude {key} is not supported for drain executors")
159
+ if key != "--add-dir":
160
+ continue
161
+ directories = [value] if equal else []
162
+ for directory in argv[index + 1:]:
163
+ if directory.startswith("-"):
164
+ break
165
+ directories.append(directory)
166
+ if not directories:
167
+ raise PolicyError("unattended tools: Claude --add-dir requires a directory")
168
+ for directory in directories:
169
+ for filename in ("settings.json", "settings.local.json"):
170
+ path = Path(cwd) / Path(directory).expanduser() / ".claude" / filename
171
+ try:
172
+ raw = path.read_text(encoding="utf-8")
173
+ except FileNotFoundError:
174
+ continue
175
+ except (OSError, UnicodeError) as exc:
176
+ raise PolicyError(f"unattended tools: cannot read Claude --add-dir settings {path}: {exc}") from exc
177
+ try:
178
+ settings = LOADS(raw)
179
+ if not isinstance(settings, dict):
180
+ raise ValueError("expected a settings object")
181
+ if "enabledPlugins" in settings:
182
+ raise ValueError("enabledPlugins cannot be verified for --add-dir")
183
+ except ValueError as exc:
184
+ raise PolicyError(f"unattended tools: Claude --add-dir settings {path}: {exc}") from exc
185
+ env = {**os.environ, "ENABLE_CLAUDEAI_MCP_SERVERS": "false"}
186
+ plugins = listed([*native, "--no-chrome", "plugin", "list", "--json"], cwd, "Claude plugin list --json", "id", env)
187
+ targeted = {p["id"] for p in plugins if p["enabled"]}
188
+ # Claude 2.1.314 recognizes disableClaudeAiConnectors; also override settings.env.
189
+ settings = {"syncClaudeAiPlugins": False, "disableClaudeAiConnectors": True,
190
+ "env": {"ENABLE_CLAUDEAI_MCP_SERVERS": "false"},
191
+ "enabledPlugins": dict.fromkeys(sorted(targeted), False)}
192
+ with tempfile.TemporaryDirectory(prefix="devlyn-unattended-") as temporary:
193
+ path = Path(temporary) / "settings.json"
194
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
195
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
196
+ json.dump(settings, stream, ensure_ascii=False)
197
+ options = ["--settings", str(path)]
198
+ if denied:
199
+ options.append("--disallowedTools=" + ",".join("mcp__" + claude_namespace(n) for n in denied))
200
+ options.append("--no-chrome")
201
+ verification = listed([*native, *options, "plugin", "list", "--json"], cwd,
202
+ "Claude plugin list --json verification", "id", env)
203
+ enabled = sorted({p["id"] for p in verification if p["enabled"]})
204
+ if enabled:
205
+ raise PolicyError(f"unattended tools: Claude plugin overrides did not disable: {', '.join(enabled)}; check managed settings")
206
+ yield [argv[0], *options, *argv[1:]], env
@@ -110,7 +110,7 @@ Follow [loop.md](references/loop.md). The executor is the configured route, the
110
110
 
111
111
  | Executor | argv |
112
112
  |---|---|
113
- | Claude | `claude -p "<prompt>" --dangerously-skip-permissions --add-dir "<git dir>"` |
113
+ | Claude | `claude -p "<prompt>" --dangerously-skip-permissions --add-dir "<git dir>"` (drain prepends `--settings <temporary-policy.json> --no-chrome` and any `--disallowedTools=<list>` denies after `claude`) |
114
114
  | Codex | `bash "<DEVLYN_SHARED_DIR>/codex-monitored.sh" --skip-git-repo-check -s workspace-write -c 'sandbox_workspace_write.writable_roots=["<git dir>","{worktree_git_dir}"]' -c 'sandbox_workspace_write.network_access=true' "<prompt>"` |
115
115
 
116
116
  Another engine needs an argv that starts one fresh non-interactive session able, without prompts, to edit, run commands and commit in its task worktree and to write the packet's submission file. `<prompt>` carries the absolute binding values, while `{packet}` stays literal for the drain to fill: