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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +19 -0
- package/bin/instruction-templates.json +120 -0
- package/bin/skill-history.json +11 -0
- package/config/skills/_shared/adapters/claude.md +15 -0
- package/config/skills/_shared/adapters/codex.md +10 -0
- package/config/skills/_shared/invocation-receipt.py +179 -9
- package/config/skills/_shared/platform-support.py +102 -1
- package/config/skills/_shared/runtime-principles.md +1 -1
- package/config/skills/_shared/unattended-tools.py +206 -0
- package/config/skills/devlyn-ideate/SKILL.md +1 -1
- package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +24 -11
- package/harness/AGENTS.md +2 -2
- package/harness/CLAUDE.md +2 -2
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +10 -0
- package/native/claude/skills/_shared/invocation-receipt.py +179 -9
- package/native/claude/skills/_shared/platform-support.py +102 -1
- package/native/claude/skills/_shared/runtime-principles.md +1 -1
- package/native/claude/skills/_shared/unattended-tools.py +206 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +24 -11
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +24 -11
- package/package.json +1 -1
- 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,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
454
|
-
|
|
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
|
|
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:
|