devlyn-cli 4.3.17 → 4.3.20

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 (31) hide show
  1. package/README.md +19 -0
  2. package/bin/skill-history.json +9 -0
  3. package/config/skills/_shared/adapters/claude.md +15 -0
  4. package/config/skills/_shared/adapters/codex.md +10 -0
  5. package/config/skills/_shared/invocation-receipt.py +179 -9
  6. package/config/skills/_shared/platform-support.py +102 -1
  7. package/config/skills/_shared/unattended-tools.py +206 -0
  8. package/config/skills/devlyn-ideate/SKILL.md +1 -1
  9. package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  10. package/config/skills/devlyn-ideate/scripts/queue.py +24 -11
  11. package/native/claude/.claude-plugin/plugin.json +1 -1
  12. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  13. package/native/claude/skills/_shared/adapters/codex.md +10 -0
  14. package/native/claude/skills/_shared/invocation-receipt.py +179 -9
  15. package/native/claude/skills/_shared/platform-support.py +102 -1
  16. package/native/claude/skills/_shared/unattended-tools.py +206 -0
  17. package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
  18. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  19. package/native/claude/skills/devlyn-ideate/scripts/queue.py +24 -11
  20. package/native/codex/.agents/plugins/marketplace.json +1 -1
  21. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  22. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  23. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
  24. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
  25. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
  26. package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
  27. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
  28. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  29. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +24 -11
  30. package/package.json +1 -1
  31. package/release.json +44 -29
@@ -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:
@@ -17,6 +17,7 @@ import hashlib
17
17
  import json
18
18
  import os
19
19
  from pathlib import Path
20
+ import platform
20
21
  import re
21
22
  import runpy
22
23
  import subprocess
@@ -454,7 +455,17 @@ def derive(packet, submission, result):
454
455
 
455
456
 
456
457
  class AcceptanceTests(unittest.TestCase):
458
+ def macho_header(self):
459
+ cpu = {"arm64": 0x0100000c, "x86_64": 0x01000007}[platform.machine()]
460
+ return bytes.fromhex("cffaedfe") + cpu.to_bytes(4, "little") + bytes(24)
461
+
457
462
  def setUp(self):
463
+ if sys.platform != "darwin" and os.name != "nt":
464
+ # Darwin argv tests emulate an unquarantined script; Linux has a different xattr namespace.
465
+ import errno
466
+ from unittest import mock
467
+ self.enterContext(mock.patch.object(errno, "ENOATTR", errno.ENODATA, create=True))
468
+ self.enterContext(mock.patch.object(os, "getxattr", side_effect=OSError(errno.ENOATTR, "attribute absent")))
458
469
  self.temp = tempfile.TemporaryDirectory(prefix="devlyn-acceptance ")
459
470
  self.addCleanup(self.temp.cleanup)
460
471
  self.root = Path(self.temp.name).resolve()
@@ -665,6 +676,300 @@ class AcceptanceTests(unittest.TestCase):
665
676
  self.assertNotIn("reused_from", result["commands"][0])
666
677
  self.assertEqual(((self.work / ".devlyn/count").read_text(), result["verdict"]), ("2", "ACCEPTED"))
667
678
 
679
+ @unittest.skipIf(os.name == "nt", "requires POSIX executable permissions and paths")
680
+ def test_darwin_runs_shebang_programs_through_their_interpreter(self):
681
+ """Prediction: Darwin's 512-byte shebang rules preserve argv and candidate spelling; unsafe rewrites
682
+ preserve the original command and CPython's PATH search without a shell fallback."""
683
+ from types import SimpleNamespace
684
+ from unittest import mock
685
+ resolve = shared("platform-support")["native_argv"]
686
+ with tempfile.TemporaryDirectory(prefix="devlyn-shebang-") as temporary, \
687
+ mock.patch.object(platform, "machine", return_value="arm64"), \
688
+ mock.patch.dict(resolve.__globals__, {"sys": SimpleNamespace(platform="darwin")}):
689
+ root = Path(temporary).resolve()
690
+ interpreter = root / "interpreter"
691
+ interpreter.write_bytes(self.macho_header()); interpreter.chmod(0o755)
692
+ p = os.fsencode(interpreter)
693
+ script = root / "s"
694
+ cases = [
695
+ ("1", b"#!" + p + b"\n", []),
696
+ ("2", b"#! " + p + b" a b\tc \n", ["a", "b", "c"]),
697
+ ("3", b"#!" + p + b" a#b c\n", ["a"]),
698
+ ("4", b"#!" + p + b" a # c\n", ["a"]),
699
+ ("5", b"#!" + p + b" #", []),
700
+ ("6", b"#!" + p + b" a\r\n", ["a\r"]),
701
+ ("7", b"#!" + p + b"\r\n", None),
702
+ ("8a", b"#!" + p, None), ("8b", b"#!" + p + b" a", None),
703
+ ("9a", b"#! \n", None), ("9b", b"#! #x\n", None),
704
+ ("10", b"#!" + p + b" a\0b c\n", None),
705
+ ("11", b"#!interpreter\n", None),
706
+ ("14", b"#!/usr/bin/env P a b\n", ["/usr/bin/env", "P", "a", "b"]),
707
+ ("15a", b"#!" + p + b" " * (509 - len(p)) + b"\n", []),
708
+ ("15b", b"#!" + p + b" " * (510 - len(p)) + b"\n", None),
709
+ ("17", b"plain text\n", None),
710
+ ("blank-limit", b"#!" + b" " * 510 + b"\n", None),
711
+ ]
712
+ for vector, header, arguments in cases:
713
+ with self.subTest(vector=vector):
714
+ if vector == "14" and sys.platform != "darwin":
715
+ self.skipTest("/usr/bin/env is a Mach-O image only on macOS")
716
+ script.write_bytes(header); script.chmod(0o755)
717
+ expected = [str(script), "X"] if arguments is None else [str(interpreter), *arguments, str(script), "X"]
718
+ if vector == "14":
719
+ expected = [*arguments, str(script), "X"]
720
+ self.assertEqual(resolve([str(script), "X"]), expected)
721
+
722
+ script.write_bytes(b"#!" + p + b"\n")
723
+ for mode, body in ((0o644, b"binary"), (0o755, b"#!/bin/sh\n"), (0o755, b"")):
724
+ with self.subTest(vector="12/13", mode=mode, body=body):
725
+ interpreter.write_bytes(body); interpreter.chmod(mode)
726
+ self.assertEqual(resolve([str(script), "X"]), [str(script), "X"])
727
+ interpreter.write_bytes(self.macho_header()); interpreter.chmod(0o755)
728
+ for mode in (0o4755, 0o2755, 0o644):
729
+ with self.subTest(vector="18/17", mode=mode):
730
+ script.chmod(mode)
731
+ if script.stat().st_mode & 0o7777 != mode:
732
+ self.skipTest("filesystem strips set-id bits")
733
+ self.assertEqual(resolve([str(script), "X"]), [str(script), "X"])
734
+ script.chmod(0o755)
735
+ folder = root / "D"; folder.mkdir()
736
+ link = root / "link"; link.symlink_to(script)
737
+ for candidate in (str(root) + "/D/../x//s", str(link), "./s"):
738
+ with self.subTest(vector="16/23", candidate=candidate):
739
+ (root / "x").mkdir(exist_ok=True)
740
+ (root / "x/s").write_bytes(script.read_bytes()); (root / "x/s").chmod(0o755)
741
+ self.assertEqual(resolve([candidate, "X"], cwd=root), [str(interpreter), candidate, "X"])
742
+
743
+ a, b = root / "a", root / "b"
744
+ a.mkdir(); b.mkdir()
745
+ second = b / "s"; second.write_bytes(script.read_bytes()); second.chmod(0o755)
746
+ first = a / "s"; first.mkdir()
747
+ with mock.patch.dict(os.environ, {"PATH": f"{a}:{b}"}):
748
+ self.assertEqual(resolve(["s", "X"], cwd=root), [str(interpreter), str(second), "X"])
749
+ first.rmdir(); first.write_bytes(script.read_bytes()); first.chmod(0o644)
750
+ self.assertEqual(resolve(["s", "X"], cwd=root), [str(interpreter), str(second), "X"])
751
+ first.write_bytes(b"#!/missing-interpreter\n"); first.chmod(0o755)
752
+ self.assertEqual(resolve(["s", "X"], cwd=root), ["s", "X"]) # 19: CPython must retry b/s.
753
+ for path, candidate in ((f"missing:{b}", str(second)), (f"missing::{b}", "s"), (f":{b}", "s"),
754
+ (f"b:{b}", "b/s")):
755
+ with self.subTest(vector="21", path=path), mock.patch.dict(os.environ, {"PATH": path}):
756
+ self.assertEqual(resolve(["s", "X"], cwd=root), [str(interpreter), candidate, "X"])
757
+ for entry in ("/" + "x" * 1021, "/" + "é" * 511):
758
+ with self.subTest(vector="22", entry=entry), mock.patch.dict(os.environ, {"PATH": f"{entry}:{b}"}):
759
+ self.assertEqual(resolve(["s", "X"], cwd=root), ["s", "X"])
760
+ with mock.patch.dict(os.environ, {"PATH": str(b)}), mock.patch.object(os, "getcwd", return_value=str(root)):
761
+ self.assertEqual(resolve(["./s", "X"]), [str(interpreter), "./s", "X"])
762
+ with mock.patch.object(os, "getcwd", side_effect=OSError("cwd unavailable")):
763
+ self.assertEqual(resolve(["s", "X"]), ["s", "X"])
764
+
765
+ @unittest.skipIf(os.name == "nt", "requires POSIX executable permissions and paths")
766
+ def test_darwin_interpreter_eligibility(self):
767
+ """Prediction: only executable, regular, non-set-id Mach-O interpreters with a native CPU and subtype rewrite;
768
+ capability bits are ignored, arm64e is accepted, and x86_64h and unknown subtypes are declined."""
769
+ import shutil
770
+ import stat
771
+ from types import SimpleNamespace
772
+ from unittest import mock
773
+ helper = shared("platform-support")
774
+ resolve = helper["native_argv"]
775
+ prefix = helper["darwin_script_argv"]
776
+ with tempfile.TemporaryDirectory(prefix="devlyn-interpreter-") as temporary, \
777
+ mock.patch.object(platform, "machine", return_value="arm64"), \
778
+ mock.patch.dict(resolve.__globals__, {"sys": SimpleNamespace(platform="darwin")}):
779
+ root = Path(temporary).resolve()
780
+ interpreter = root / "interpreter"
781
+ script = root / "s"
782
+ script.write_bytes(b"#!" + os.fsencode(interpreter) + b"\n"); script.chmod(0o755)
783
+ interpreter.mkdir()
784
+ self.assertIsNone(prefix(str(script), str(root)))
785
+ interpreter.rmdir()
786
+ for body in (b"plain text\n", b"", b"#!/bin/sh\n", b"\xcf\xfa\xed", b"\x7fELF"):
787
+ with self.subTest(body=body):
788
+ interpreter.write_bytes(body); interpreter.chmod(0o755)
789
+ self.assertIsNone(prefix(str(script), str(root)))
790
+ self.assertEqual(resolve([str(script), "X"]), [str(script), "X"])
791
+ for machine, cpu in (("arm64", 0x0100000c), ("x86_64", 0x01000007)):
792
+ for magic in ("feedface", "feedfacf", "cefaedfe", "cffaedfe",
793
+ "cafebabe", "bebafeca", "cafebabf", "bfbafeca"):
794
+ order = "little" if magic in ("cefaedfe", "cffaedfe", "bebafeca", "bfbafeca") else "big"
795
+ fat = magic in ("cafebabe", "bebafeca", "cafebabf", "bfbafeca")
796
+ if fat:
797
+ size = 32 if magic in ("cafebabf", "bfbafeca") else 20
798
+ else:
799
+ size = 32 if magic in ("feedfacf", "cffaedfe") else 28
800
+ for image_cpu, subtype, accepted in ((0x01000007, 0, False), (0x01000007, 3, True), (0x01000007, 8, False),
801
+ (0x0100000c, 0, True), (0x0100000c, 1, True),
802
+ (0x0100000c, 0x80000002, True), (0x0100000c, 9, False)):
803
+ image = bytes.fromhex(magic)
804
+ if fat:
805
+ image += (1).to_bytes(4, order)
806
+ image += image_cpu.to_bytes(4, order) + subtype.to_bytes(4, order) + bytes(size - (8 if fat else 12))
807
+ for truncated in (False, True):
808
+ with self.subTest(machine=machine, magic=magic, cpu=image_cpu, subtype=subtype, truncated=truncated), \
809
+ mock.patch.object(platform, "machine", return_value=machine):
810
+ interpreter.write_bytes(image[:-1] if truncated else image); interpreter.chmod(0o755)
811
+ compatible = cpu == image_cpu and accepted
812
+ expected = [str(interpreter), str(script)] if compatible and not truncated else None
813
+ self.assertEqual(prefix(str(script), str(root)), expected)
814
+ self.assertEqual(resolve([str(script), "X"]), [*expected, "X"] if expected else [str(script), "X"])
815
+ if fat:
816
+ # A later native slice must also be found in a universal image.
817
+ universal = bytes.fromhex(magic) + (2).to_bytes(4, order)
818
+ universal += b"".join(value.to_bytes(4, order) + subtype.to_bytes(4, order) + bytes(size - 8)
819
+ for value, subtype in ((0x01000007, 3), (0x0100000c, 0)))
820
+ with mock.patch.object(platform, "machine", return_value=machine):
821
+ interpreter.write_bytes(universal)
822
+ self.assertEqual(prefix(str(script), str(root)), [str(interpreter), str(script)])
823
+ interpreter.write_bytes(self.macho_header())
824
+ interpreter.chmod(0o644)
825
+ self.assertIsNone(prefix(str(script), str(root)))
826
+ link = root / "link"; link.symlink_to(interpreter)
827
+ for bit in (stat.S_ISUID, stat.S_ISGID):
828
+ with self.subTest(set_id=oct(bit)):
829
+ interpreter.chmod(0o755 | bit)
830
+ if not interpreter.stat().st_mode & bit:
831
+ self.skipTest(f"filesystem or sandbox strips interpreter set-id bit {oct(bit)}")
832
+ for target in (interpreter, link):
833
+ script.write_bytes(b"#!" + os.fsencode(target) + b"\n")
834
+ self.assertIsNone(prefix(str(script), str(root)))
835
+ self.assertEqual(resolve([str(script), "X"]), [str(script), "X"])
836
+ for kind in ("copy", "symlink"):
837
+ with self.subTest(shell=kind):
838
+ if sys.platform != "darwin":
839
+ self.skipTest("/bin/sh is a Mach-O image only on macOS")
840
+ shell = root / kind
841
+ if kind == "copy":
842
+ shutil.copyfile("/bin/sh", shell); shell.chmod(0o755)
843
+ else:
844
+ shell.symlink_to("/bin/sh")
845
+ script.write_bytes(b"#!" + os.fsencode(shell) + b"\n")
846
+ self.assertEqual(prefix(str(script), str(root)), [str(shell), str(script)])
847
+ self.assertEqual(resolve([str(script), "X"]), [str(shell), str(script), "X"])
848
+
849
+ @unittest.skipIf(os.name == "nt", "requires POSIX executable permissions and paths")
850
+ def test_darwin_relative_candidates_and_path_errors(self):
851
+ """Prediction: a cwd join exceeding PATH_MAX still rewrites; directory search errors stop PATH inspection."""
852
+ import contextlib
853
+ import errno
854
+ from types import SimpleNamespace
855
+ from unittest import mock
856
+ helper = shared("platform-support")
857
+ resolve = helper["native_argv"]
858
+ prefix = helper["darwin_script_argv"]
859
+ with tempfile.TemporaryDirectory(prefix="devlyn-relative-") as temporary, \
860
+ mock.patch.object(platform, "machine", return_value="arm64"), \
861
+ mock.patch.dict(resolve.__globals__, {"sys": SimpleNamespace(platform="darwin")}), \
862
+ contextlib.ExitStack() as handles:
863
+ root = Path(temporary).resolve()
864
+ interpreter = root / "interpreter"
865
+ interpreter.write_bytes(self.macho_header()); interpreter.chmod(0o755)
866
+ body = b"#!" + os.fsencode(interpreter) + b"\n"
867
+ directory = "d" * 100
868
+ candidate = directory + "/s"
869
+ cwd = str(root)
870
+ fd = os.open(cwd, os.O_RDONLY | os.O_DIRECTORY)
871
+ handles.callback(os.close, fd)
872
+ for _ in range(10):
873
+ os.mkdir(directory, dir_fd=fd)
874
+ child_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd)
875
+ handles.callback(os.close, child_fd)
876
+ with os.fdopen(os.open("s", os.O_WRONLY | os.O_CREAT, 0o755, dir_fd=child_fd), "wb") as stream:
877
+ stream.write(body)
878
+ with mock.patch.dict(os.environ, {"PATH": directory}):
879
+ self.assertEqual(prefix("s", cwd), [str(interpreter), candidate])
880
+ self.assertEqual(resolve(["s", "X"], cwd=cwd), [str(interpreter), candidate, "X"])
881
+ self.assertEqual(prefix("./" + candidate, cwd), [str(interpreter), "./" + candidate])
882
+ if len(os.fsencode(os.path.join(cwd, candidate))) > 1024:
883
+ break
884
+ cwd = os.path.join(cwd, directory)
885
+ fd = child_fd
886
+ else:
887
+ self.fail("fixture must exceed Darwin PATH_MAX")
888
+ if sys.platform == "darwin":
889
+ with self.assertRaises(OSError) as caught:
890
+ os.stat(os.path.join(cwd, candidate))
891
+ self.assertEqual(caught.exception.errno, errno.ENAMETOOLONG)
892
+
893
+ blocked = root / "blocked"; blocked.mkdir()
894
+ first = blocked / "s"; first.write_bytes(body); first.chmod(0o755)
895
+ second = root / "s"; second.write_bytes(body); second.chmod(0o755)
896
+ with mock.patch.dict(os.environ, {"PATH": f"{blocked}:{root}"}):
897
+ self.assertEqual(prefix("s", str(root)), [str(interpreter), str(first)])
898
+ blocked.chmod(0o600)
899
+ try:
900
+ if os.geteuid() == 0:
901
+ self.skipTest("root bypasses directory search permission")
902
+ with self.assertRaises(PermissionError):
903
+ first.stat()
904
+ self.assertIsNone(prefix("s", str(root)))
905
+ self.assertEqual(resolve(["s", "X"], cwd=root), ["s", "X"])
906
+ finally:
907
+ blocked.chmod(0o700)
908
+ # ENAMETOOLONG is not an absent candidate, even if a later PATH entry works.
909
+ with mock.patch.dict(os.environ, {"PATH": f"{'x' * 256}:{root}"}):
910
+ self.assertIsNone(prefix("s", str(root)))
911
+
912
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS quarantine attributes")
913
+ def test_darwin_script_quarantine(self):
914
+ """Prediction: quarantine presence (including empty values and symlink targets) and xattr errors decline."""
915
+ import errno
916
+ from types import SimpleNamespace
917
+ from unittest import mock
918
+ helper = shared("platform-support")
919
+ resolve = helper["native_argv"]
920
+ prefix = helper["darwin_script_argv"]
921
+ with tempfile.TemporaryDirectory(prefix="devlyn-quarantine-") as temporary, \
922
+ mock.patch.object(platform, "machine", return_value="arm64"), \
923
+ mock.patch.dict(resolve.__globals__, {"sys": SimpleNamespace(platform="darwin")}):
924
+ root = Path(temporary).resolve()
925
+ interpreter = root / "interpreter"
926
+ interpreter.write_bytes(self.macho_header()); interpreter.chmod(0o755)
927
+ script = root / "s"
928
+ script.write_bytes(b"#!" + os.fsencode(interpreter) + b"\n"); script.chmod(0o755)
929
+ link = root / "link"; link.symlink_to(script)
930
+ for value in (None, "0083;00000000;Test;", ""):
931
+ if value is not None:
932
+ if hasattr(os, "setxattr"):
933
+ os.setxattr(script, "com.apple.quarantine", value.encode())
934
+ else:
935
+ subprocess.run(["/usr/bin/xattr", "-w", "com.apple.quarantine", value, str(script)], check=True)
936
+ for target in (script, link):
937
+ with self.subTest(value=value, target=target.name):
938
+ expected = [str(interpreter), str(target)] if value is None else None
939
+ self.assertEqual(prefix(str(target), str(root)), expected)
940
+ self.assertEqual(resolve([str(target), "X"]), [*expected, "X"] if expected else [str(target), "X"])
941
+ # Exercise the os.getxattr branch as well, including fail-closed errors.
942
+ for error in (errno.ENOATTR, errno.EACCES, errno.ENOTSUP, errno.EIO):
943
+ with self.subTest(error=error), \
944
+ mock.patch.object(os, "getxattr", create=True, side_effect=OSError(error, os.strerror(error))):
945
+ expected = [str(interpreter), str(script)] if error == errno.ENOATTR else None
946
+ self.assertEqual(prefix(str(script), str(root)), expected)
947
+ with mock.patch.object(os, "getxattr", create=True, return_value=b""):
948
+ self.assertIsNone(prefix(str(script), str(root)))
949
+
950
+ @unittest.skipIf(os.name == "nt", "requires POSIX executable permissions and paths")
951
+ def test_darwin_path_unset_uses_cpython_default(self):
952
+ """Prediction: unset PATH searches os.get_exec_path() in order, including os.defpath."""
953
+ from types import SimpleNamespace
954
+ from unittest import mock
955
+ resolve = shared("platform-support")["native_argv"]
956
+ with tempfile.TemporaryDirectory(prefix="devlyn-default-path-") as temporary, \
957
+ mock.patch.object(platform, "machine", return_value="arm64"), \
958
+ mock.patch.dict(resolve.__globals__, {"sys": SimpleNamespace(platform="darwin")}), \
959
+ mock.patch.dict(os.environ):
960
+ os.environ.pop("PATH", None)
961
+ root = Path(temporary).resolve()
962
+ interpreter = root / "interpreter"
963
+ interpreter.write_bytes(self.macho_header()); interpreter.chmod(0o755)
964
+ for name in ("a", "b"):
965
+ folder = root / name; folder.mkdir()
966
+ script = folder / "s"
967
+ script.write_bytes(b"#!" + os.fsencode(interpreter) + b"\n"); script.chmod(0o755)
968
+ for default in (f"{root}/a:{root}/b", f"{root}/b:{root}/a"):
969
+ with self.subTest(default=default), mock.patch.object(os, "defpath", default):
970
+ candidate = os.path.join(os.get_exec_path()[0], "s")
971
+ self.assertEqual(resolve(["s", "X"], cwd=root), [str(interpreter), candidate, "X"])
972
+
668
973
  def test_windows_runs_node_command_shims_without_cmd(self):
669
974
  """Windows resolution mocked: verification argv such as ["npm", "test"] runs the Node and script its command
670
975
  shim names, as cmd.exe would resolve it; any other batch file is refused with an actionable message."""
@@ -703,7 +1008,8 @@ class AcceptanceTests(unittest.TestCase):
703
1008
  def npm_prefix(argv, cwd=None, **kwargs): # what node npm-prefix.js prints, recorded with the directory it ran in
704
1009
  lookups.append((argv, cwd))
705
1010
  return SimpleNamespace(stdout=f"{prefix}\n")
706
- windows = {"os": SimpleNamespace(name="nt"), "shutil": SimpleNamespace(which=lambda name: str(found[name]) if name in found else None),
1011
+ windows = {"os": SimpleNamespace(name="nt"), "sys": SimpleNamespace(platform="win32"),
1012
+ "shutil": SimpleNamespace(which=lambda name: str(found[name]) if name in found else None),
707
1013
  "subprocess": SimpleNamespace(run=npm_prefix)}
708
1014
  with mock.patch.dict(platform["native_argv"].__globals__, windows):
709
1015
  resolve = platform["native_argv"]
@@ -1243,17 +1243,30 @@ def ensure_submission(identity, packet_path, packet, executor):
1243
1243
  output = Path(packet["evidence_dir"])
1244
1244
  output.mkdir(parents=True, exist_ok=True)
1245
1245
  record(log, "start")
1246
- try:
1247
- # Files, never a pipe: a wrapper such as codex-monitored.sh refuses a piped stdout; stdin is empty, never the driver's.
1248
- with (output / "executor.stdout").open("ab") as stdout, (output / "executor.stderr").open("ab") as stderr:
1249
- child = subprocess.Popen(shared("platform-support")["native_argv"](argv, cwd=worktree), cwd=worktree, stdin=subprocess.DEVNULL,
1250
- stdout=stdout, stderr=stderr)
1251
- except OSError as exc:
1252
- record(log, f"not started: {one_line(str(exc))}")
1253
- raise LoopError(f"executor could not start: {exc}; restore the configured executable, permissions or task workspace; "
1254
- "the next drain retries") from exc
1255
- progress(identity, f"executing; output in {output}")
1256
- code = child.wait()
1246
+ with contextlib.ExitStack() as stack:
1247
+ try:
1248
+ env = None
1249
+ claude_names = {"claude", "claude.exe", "claude.cmd", "claude.bat"}
1250
+ if Path(argv[0]).name.lower() in claude_names:
1251
+ argv, env = stack.enter_context(shared("unattended-tools")["claude_launch"](argv, worktree))
1252
+ else:
1253
+ paths = [Path(part.replace("\\", "/")) for part in argv]
1254
+ node_wrapper = any(path.name.lower() in {"node", "node.exe"} for path in paths)
1255
+ for part, path in zip(argv, paths):
1256
+ if path.name.lower() in claude_names or (path.name.lower() == "cli.js" and
1257
+ (node_wrapper or any("claude" in parent.lower() for parent in path.parts[:-1]))):
1258
+ raise ValueError(f"unattended tools: wrapped Claude or ambiguous Node CLI executor {argv[0]!r} via {part!r} is unsupported; "
1259
+ "invoke claude directly as argv[0]")
1260
+ # Files, never a pipe: a wrapper such as codex-monitored.sh refuses a piped stdout; stdin is empty, never the driver's.
1261
+ with (output / "executor.stdout").open("ab") as stdout, (output / "executor.stderr").open("ab") as stderr:
1262
+ child = subprocess.Popen(shared("platform-support")["native_argv"](argv, cwd=worktree), cwd=worktree, stdin=subprocess.DEVNULL,
1263
+ stdout=stdout, stderr=stderr, env=env)
1264
+ except (OSError, ValueError) as exc:
1265
+ record(log, f"not started: {one_line(str(exc))}")
1266
+ raise LoopError(f"executor could not start: {exc}; restore the configured executable, permissions or task workspace; "
1267
+ "the next drain retries") from exc
1268
+ progress(identity, f"executing; output in {output}")
1269
+ code = child.wait()
1257
1270
  record(log, f"exit {code} (pid {child.pid})")
1258
1271
  if not submission.exists():
1259
1272
  raise Waiting(f"executor exited {code} without a submission (see {output / 'executor.stderr'}); the next drain runs it again")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.17",
3
+ "version": "4.3.20",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -7,6 +7,21 @@ A Claude executor starts as
7
7
  where `<common git dir>` is `git rev-parse --path-format=absolute --git-common-dir`:
8
8
  a linked task worktree commits there.
9
9
 
10
+ For drain children, `queue.py` adds `--no-chrome` and a temporary `--settings`
11
+ file (0600), sets `ENABLE_CLAUDEAI_MCP_SERVERS=false`, disables plugin sync and
12
+ every enabled plugin (including synced plugins), and verifies the overrides
13
+ before spawning. Discovery or verification failure refuses the launch. Normal
14
+ user settings, including `BASH_MAX_TIMEOUT_MS`, remain loaded; interactive and
15
+ non-drain Claude invocations are unchanged.
16
+ The launch settings also force `env.ENABLE_CLAUDEAI_MCP_SERVERS="false"` and
17
+ `disableClaudeAiConnectors=true` (recognized by Claude 2.1.314). Policy options
18
+ precede all caller arguments; server denies use `--disallowedTools=<list>`.
19
+ Drain refuses caller `--settings`, `--setting-sources`, `--plugin-dir` and
20
+ `--plugin-url` or `--chrome` tokens (including `=<value>` forms), and `--add-dir` directories
21
+ whose `.claude/settings.json` or `settings.local.json` contains `enabledPlugins`.
22
+ Wrapped Claude executors must be replaced with a direct Claude argv; a shell
23
+ command or script that starts Claude itself is not inspected.
24
+
10
25
  **Availability probe**: `command -v claude >/dev/null 2>&1`; record
11
26
  `claude --version` as evidence. The probe is necessary, not sufficient:
12
27
  `claude -p` needs network access to the Anthropic API, so in a network-denying
@@ -12,3 +12,13 @@ denies network, which installing a new dependency needs:
12
12
  `-s workspace-write -c 'sandbox_workspace_write.writable_roots=["<git dir>","<worktree git dir>"]' -c 'sandbox_workspace_write.network_access=true'`.
13
13
  Deliver a multiline prompt as exact file bytes: set `DEVLYN_CODEX_PROMPT_FILE=<file>`
14
14
  and pass the sole prompt argument `-`.
15
+
16
+ Every monitored child applies `_shared/unattended-tools.py` before recording its
17
+ effective argv: account/plugin and browser/computer features are disabled, then
18
+ listed desktop servers are disabled. Policy switches precede all exec arguments;
19
+ feature states and MCP overrides are read back before launch. A still-enabled
20
+ target, malformed output or discovery failure refuses the launch; an unsupported feature error names the installed Codex version.
21
+ Interactive Codex and user configuration files are unchanged.
22
+ If an exec-only configuration option (such as `--ignore-user-config` on Codex
23
+ 0.164.0) cannot be applied to `mcp list`, discovery refuses the launch; it never
24
+ falls back to listing a different configuration.