syntaur 0.32.0 → 0.33.0
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/dist/index.js +763 -451
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/README.md +38 -7
- package/platforms/hermes/plugins/syntaur/README.md +37 -0
- package/platforms/hermes/plugins/syntaur/__init__.py +151 -0
- package/platforms/hermes/plugins/syntaur/__pycache__/__init__.cpython-312.pyc +0 -0
- package/platforms/hermes/plugins/syntaur/__pycache__/boundary.cpython-312.pyc +0 -0
- package/platforms/hermes/plugins/syntaur/boundary.py +99 -0
- package/platforms/hermes/plugins/syntaur/plugin.yaml +6 -0
- package/platforms/pi/extensions/syntaur/README.md +34 -0
- package/platforms/pi/extensions/syntaur/index.ts +265 -0
package/package.json
CHANGED
package/platforms/README.md
CHANGED
|
@@ -21,6 +21,31 @@ Driven declaratively by the registry in `src/targets/registry.ts`.
|
|
|
21
21
|
| **OpenClaw** | `AGENTS.md` | OpenClaw reads `AGENTS.md` (built on Pi) |
|
|
22
22
|
| **Hermes Agent** | `SOUL.md` | Hermes reads `SOUL.md` / context files |
|
|
23
23
|
|
|
24
|
+
**User-authored agents:** end users can register a brand-new Tier-1+Tier-2 agent
|
|
25
|
+
WITHOUT a Syntaur release by dropping a JSON descriptor in `~/.syntaur/targets/`.
|
|
26
|
+
See `references/user-targets.md`.
|
|
27
|
+
|
|
28
|
+
## Tier-3 deep enforcement plugins (pi / OpenClaw / Hermes)
|
|
29
|
+
|
|
30
|
+
Tier-3 brings the **same enforcement parity the Claude Code / Codex plugins have** —
|
|
31
|
+
write-boundary blocking + session cleanup + Syntaur slash commands — to agents that
|
|
32
|
+
support imperative plugins. Installed automatically when the agent is targeted
|
|
33
|
+
(`syntaur setup --target pi`); `syntaur doctor` reports Tier-3 install status.
|
|
34
|
+
|
|
35
|
+
| Agent | Plugin | Installed to | Enforcement |
|
|
36
|
+
|-------|--------|--------------|-------------|
|
|
37
|
+
| **Pi** | TypeScript extension (`platforms/pi/extensions/syntaur/`) | `~/.pi/agent/extensions/syntaur/` | `tool_call` → block out-of-boundary writes; `session_shutdown` → mark session stopped |
|
|
38
|
+
| **OpenClaw** | reuses the **pi** extension (runs on pi-coding-agent) | `~/.openclaw/extensions/syntaur/` | same as Pi |
|
|
39
|
+
| **Hermes** | Python plugin (`platforms/hermes/plugins/syntaur/`) | `~/.hermes/plugins/syntaur/` | `pre_tool_call` → log + best-effort block; `on_session_end` → mark session stopped |
|
|
40
|
+
|
|
41
|
+
Caveats (see each plugin's `README.md`): **OpenClaw** is assumed to run on
|
|
42
|
+
pi-coding-agent per the design memo — if a build diverges to its own plugin format,
|
|
43
|
+
only the install dir needs repointing. **Hermes** `pre_tool_call` blocking is
|
|
44
|
+
version-dependent (documented primarily as an observer hook), so the Hermes plugin
|
|
45
|
+
logs every violation in addition to returning a deny signal; verify hard-block
|
|
46
|
+
against your live runtime. The boundary logic for both is unit-tested in
|
|
47
|
+
`src/__tests__/pi-extension.test.ts` and `src/__tests__/hermes-plugin.test.ts`.
|
|
48
|
+
|
|
24
49
|
## Installing skills into any agent (Tier 1)
|
|
25
50
|
|
|
26
51
|
Syntaur's `skills/` directory is a valid Agent Skills source, so the skills
|
|
@@ -99,15 +124,21 @@ To add support for a new framework:
|
|
|
99
124
|
- Export a render function returning the file content as a string
|
|
100
125
|
- Embed protocol knowledge directly in the template literal (do not read files at runtime)
|
|
101
126
|
|
|
102
|
-
3. **
|
|
103
|
-
and
|
|
127
|
+
3. **Register the renderer** in `src/targets/renderers.ts` -- add a `RendererKey`
|
|
128
|
+
in `src/targets/types.ts` and map it to the render function in the `RENDERERS` table.
|
|
129
|
+
|
|
130
|
+
4. **Add a descriptor** to the registry array in `src/targets/registry.ts` (the
|
|
131
|
+
`SUPPORTED_FRAMEWORKS` switch was removed in the Phase-1 registry refactor):
|
|
132
|
+
one `AgentTarget` with `id`, `displayName`, `detect`, optional `skillsDir`, and an
|
|
133
|
+
`instructions.files[]` listing each protocol file + its `renderer` key.
|
|
104
134
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
135
|
+
**No-code alternative:** end users can register a Tier-1+Tier-2 agent WITHOUT a
|
|
136
|
+
Syntaur release by dropping a JSON descriptor in `~/.syntaur/targets/` -- see
|
|
137
|
+
`references/user-targets.md`. Code changes here are only needed for built-in
|
|
138
|
+
agents or new renderers.
|
|
108
139
|
|
|
109
|
-
5. **Add unit tests** in `src/__tests__/
|
|
110
|
-
the renderer produces correct output.
|
|
140
|
+
5. **Add unit tests** in `src/__tests__/targets-registry.test.ts` /
|
|
141
|
+
`src/__tests__/adapter-templates.test.ts` verifying the renderer produces correct output.
|
|
111
142
|
|
|
112
143
|
6. **Update this README** with the new framework in the table above.
|
|
113
144
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Syntaur plugin for Hermes Agent (Tier-3)
|
|
2
|
+
|
|
3
|
+
A self-contained Python plugin that brings Syntaur's Tier-3 enforcement to **Hermes Agent**, mirroring
|
|
4
|
+
the Claude Code / Codex bash hooks:
|
|
5
|
+
|
|
6
|
+
- **`pre_tool_call`** — detects writes (Hermes snake_case tools: `patch`, `write_file`, `edit_file`,
|
|
7
|
+
`create_file`, `apply_patch`) outside the active assignment boundary and **logs + best-effort blocks**
|
|
8
|
+
them. Boundary logic in `boundary.py` mirrors `platforms/claude-code/hooks/enforce-boundaries.sh`.
|
|
9
|
+
- **`on_session_end`** — marks the Syntaur dashboard session `stopped`.
|
|
10
|
+
- **Slash commands** — `doctor-syntaur` runs `syntaur doctor`; the rest point at the installed Tier-1
|
|
11
|
+
skill of the same name.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
`syntaur setup --target hermes` copies this directory into `~/.hermes/plugins/syntaur/` (or
|
|
16
|
+
`$HERMES_HOME/plugins/syntaur/`). Hermes auto-discovers `plugin.yaml` plugins there. `syntaur doctor`
|
|
17
|
+
reports Tier-3 install status.
|
|
18
|
+
|
|
19
|
+
## Layout
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
syntaur/
|
|
23
|
+
├── plugin.yaml # manifest: name, version, provides_hooks
|
|
24
|
+
├── __init__.py # register(ctx): registers the hooks + commands
|
|
25
|
+
└── boundary.py # pure write-boundary logic (unit-tested via python3)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Caveats
|
|
29
|
+
|
|
30
|
+
- **Blocking is best-effort (version-dependent).** Hermes documents `pre_tool_call` primarily as a
|
|
31
|
+
fire-and-forget observer; some versions allow a return value to block. This plugin returns a deny
|
|
32
|
+
signal AND logs every violation to stderr + `~/.syntaur/tier3-violations.log`, so enforcement is
|
|
33
|
+
observable even if a given Hermes build ignores the block. Verify hard-block behavior against your
|
|
34
|
+
live Hermes runtime.
|
|
35
|
+
- Hooks never raise (the Hermes handler contract) — all bodies are wrapped in try/except.
|
|
36
|
+
- `boundary.py` is unit-tested for real (executed via `python3`) in Syntaur's
|
|
37
|
+
`src/__tests__/hermes-plugin.test.ts`.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Syntaur Tier-3 enforcement plugin for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Registers two lifecycle hooks via `register(ctx)`:
|
|
4
|
+
- pre_tool_call : block (best-effort) + log writes outside the assignment boundary
|
|
5
|
+
- on_session_end: mark the Syntaur dashboard session "stopped"
|
|
6
|
+
|
|
7
|
+
plus the Syntaur slash commands. Stdlib only; never raises from a hook (the Hermes
|
|
8
|
+
handler contract). See README.md for the blocking-is-best-effort caveat.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from . import boundary
|
|
18
|
+
|
|
19
|
+
# Hermes write tools use the snake_case dialect (read_file / patch / terminal).
|
|
20
|
+
WRITE_TOOLS = {"patch", "write_file", "edit_file", "create_file", "apply_patch"}
|
|
21
|
+
PATH_KEYS = ("file_path", "path", "filename", "target_file")
|
|
22
|
+
|
|
23
|
+
# Slash commands match the bare CC/Codex names. Only `doctor-syntaur` shells out;
|
|
24
|
+
# the rest point the agent at the installed Tier-1 skill of the same name.
|
|
25
|
+
CORE_COMMANDS = [
|
|
26
|
+
{"name": "doctor-syntaur", "description": "Run `syntaur doctor` diagnostics", "kind": "passthrough", "argv": ["doctor"]},
|
|
27
|
+
{"name": "grab-assignment", "description": "Claim a Syntaur assignment into this session", "kind": "guidance", "skill": "grab-assignment"},
|
|
28
|
+
{"name": "log-progress", "description": "Append a progress entry to the active assignment", "kind": "guidance", "skill": "log-progress"},
|
|
29
|
+
{"name": "complete-assignment", "description": "Write a handoff and complete the assignment", "kind": "guidance", "skill": "complete-assignment"},
|
|
30
|
+
{"name": "save-session-summary", "description": "Save a session continuity summary", "kind": "guidance", "skill": "save-session-summary"},
|
|
31
|
+
{"name": "resume-session", "description": "Re-orient on the active assignment", "kind": "guidance", "skill": "resume-session"},
|
|
32
|
+
{"name": "set-workspace", "description": "Set workspace fields on the active assignment", "kind": "guidance", "skill": "set-workspace"},
|
|
33
|
+
{"name": "track-session", "description": "Register this session in the Syntaur dashboard", "kind": "guidance", "skill": "track-session"},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _extract_write_path(tool_name, args):
|
|
38
|
+
if not isinstance(tool_name, str) or tool_name.lower() not in WRITE_TOOLS:
|
|
39
|
+
return None
|
|
40
|
+
if not isinstance(args, dict):
|
|
41
|
+
return None
|
|
42
|
+
for k in PATH_KEYS:
|
|
43
|
+
v = args.get(k)
|
|
44
|
+
if isinstance(v, str) and v:
|
|
45
|
+
return v
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dashboard_port():
|
|
50
|
+
env = os.environ.get("SYNTAUR_DASHBOARD_PORT")
|
|
51
|
+
if env:
|
|
52
|
+
return env
|
|
53
|
+
try:
|
|
54
|
+
with open(os.path.join(os.path.expanduser("~"), ".syntaur", "dashboard-port"), "r") as fh:
|
|
55
|
+
return fh.read().strip() or "4800"
|
|
56
|
+
except Exception:
|
|
57
|
+
return "4800"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _log_violation(reason):
|
|
61
|
+
try:
|
|
62
|
+
sys.stderr.write("[syntaur] " + reason + "\n")
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
try:
|
|
66
|
+
log = os.path.join(os.path.expanduser("~"), ".syntaur", "tier3-violations.log")
|
|
67
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
68
|
+
with open(log, "a", encoding="utf-8") as fh:
|
|
69
|
+
fh.write(reason + "\n")
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _on_pre_tool_call(tool_name=None, args=None, task_id=None, **kwargs):
|
|
75
|
+
"""Block (best-effort) + log writes outside the assignment boundary."""
|
|
76
|
+
try:
|
|
77
|
+
path = _extract_write_path(tool_name, args)
|
|
78
|
+
if not path:
|
|
79
|
+
return None
|
|
80
|
+
cwd = os.getcwd()
|
|
81
|
+
ctx = boundary.load_context(cwd)
|
|
82
|
+
if not ctx:
|
|
83
|
+
return None
|
|
84
|
+
abs_path = path if os.path.isabs(path) else os.path.join(cwd, path)
|
|
85
|
+
context_file = os.path.join(cwd, ".syntaur", "context.json")
|
|
86
|
+
allowed, reason = boundary.is_write_allowed(abs_path, ctx, context_file)
|
|
87
|
+
if not allowed:
|
|
88
|
+
_log_violation(reason)
|
|
89
|
+
# Hermes' pre_tool_call blocking contract is version-dependent; return a
|
|
90
|
+
# deny signal as a best-effort block. If ignored, the violation is logged.
|
|
91
|
+
return {"allow": False, "reason": reason}
|
|
92
|
+
return None
|
|
93
|
+
except Exception:
|
|
94
|
+
return None # never raise from a hook
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _on_session_end(session_id=None, completed=None, interrupted=None, model=None, platform=None, **kwargs):
|
|
98
|
+
"""Mark the dashboard session stopped (best-effort)."""
|
|
99
|
+
try:
|
|
100
|
+
ctx = boundary.load_context(os.getcwd()) or {}
|
|
101
|
+
sid = ctx.get("sessionId") or session_id
|
|
102
|
+
if not sid:
|
|
103
|
+
return None
|
|
104
|
+
body = {"status": "stopped"}
|
|
105
|
+
if ctx.get("projectSlug"):
|
|
106
|
+
body["projectSlug"] = ctx["projectSlug"]
|
|
107
|
+
url = "http://127.0.0.1:%s/api/agent-sessions/%s/status" % (_dashboard_port(), sid)
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
url,
|
|
110
|
+
data=json.dumps(body).encode("utf-8"),
|
|
111
|
+
headers={"Content-Type": "application/json"},
|
|
112
|
+
method="PATCH",
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
urllib.request.urlopen(req, timeout=3)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
return None
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _make_command_handler(cmd):
|
|
124
|
+
def handler(*args, **kwargs):
|
|
125
|
+
if cmd["kind"] == "passthrough":
|
|
126
|
+
try:
|
|
127
|
+
out = subprocess.run(["syntaur"] + cmd["argv"], capture_output=True, text=True)
|
|
128
|
+
return (out.stdout or "") + (out.stderr or "")
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
return "Failed to run syntaur %s: %s" % (" ".join(cmd["argv"]), exc)
|
|
131
|
+
return (
|
|
132
|
+
'Follow the Syntaur "%s" skill (installed via skills). It derives the active '
|
|
133
|
+
"assignment/session from .syntaur/context.json." % cmd["skill"]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return handler
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def register(ctx):
|
|
140
|
+
"""Wire Syntaur's hooks + commands into Hermes. Called once at startup."""
|
|
141
|
+
ctx.register_hook("pre_tool_call", _on_pre_tool_call)
|
|
142
|
+
ctx.register_hook("on_session_end", _on_session_end)
|
|
143
|
+
|
|
144
|
+
# Command registration is best-effort across Hermes versions.
|
|
145
|
+
reg = getattr(ctx, "register_command", None)
|
|
146
|
+
if callable(reg):
|
|
147
|
+
for cmd in CORE_COMMANDS:
|
|
148
|
+
try:
|
|
149
|
+
reg(cmd["name"], _make_command_handler(cmd), description=cmd["description"])
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Pure Syntaur write-boundary logic for the Hermes plugin.
|
|
2
|
+
|
|
3
|
+
Mirrors platforms/claude-code/hooks/enforce-boundaries.sh exactly. Kept dependency-free
|
|
4
|
+
(stdlib only) and side-effect-free so it can be unit-tested directly via `python3 -c`
|
|
5
|
+
from Syntaur's vitest suite.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _expand_home(p):
|
|
13
|
+
if p == "~":
|
|
14
|
+
return os.path.expanduser("~")
|
|
15
|
+
if p.startswith("~/"):
|
|
16
|
+
return os.path.join(os.path.expanduser("~"), p[2:])
|
|
17
|
+
return p
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_context(cwd):
|
|
21
|
+
"""Read <cwd>/.syntaur/context.json; return a dict or None (fail-open)."""
|
|
22
|
+
path = os.path.join(cwd, ".syntaur", "context.json")
|
|
23
|
+
try:
|
|
24
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
25
|
+
data = json.load(fh)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def s(key):
|
|
32
|
+
v = data.get(key)
|
|
33
|
+
return v if isinstance(v, str) and v else None
|
|
34
|
+
|
|
35
|
+
def home(key):
|
|
36
|
+
v = s(key)
|
|
37
|
+
return _expand_home(v) if v else None
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"assignmentDir": home("assignmentDir"),
|
|
41
|
+
"projectDir": home("projectDir"),
|
|
42
|
+
"workspaceRoot": home("workspaceRoot"),
|
|
43
|
+
"sessionId": s("sessionId"),
|
|
44
|
+
"projectSlug": s("projectSlug"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _norm(p):
|
|
49
|
+
return os.path.normpath(os.path.abspath(p))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_under(child, parent):
|
|
53
|
+
"""True if `child` is STRICTLY under `parent` (matches the bash "$X"/* test)."""
|
|
54
|
+
if not parent:
|
|
55
|
+
return False
|
|
56
|
+
c = _norm(child)
|
|
57
|
+
p = _norm(parent)
|
|
58
|
+
if c == p:
|
|
59
|
+
return False
|
|
60
|
+
return c.startswith(p + os.sep)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_write_allowed(abs_file_path, ctx, context_file_abs=None):
|
|
64
|
+
"""Return (allowed: bool, reason: str|None) for a write to abs_file_path.
|
|
65
|
+
|
|
66
|
+
Allowed when the path is under the assignment dir, under project
|
|
67
|
+
resources/memories (excluding derived `_*` files), equal to the context file,
|
|
68
|
+
or under the workspace root. Otherwise blocked.
|
|
69
|
+
"""
|
|
70
|
+
# Parity with the bash hook (enforce-boundaries.sh:69): enforcement requires
|
|
71
|
+
# BOTH assignmentDir and projectDir. If either is missing (standalone / old /
|
|
72
|
+
# partially-written context), fail OPEN — allow everything.
|
|
73
|
+
if not ctx.get("assignmentDir") or not ctx.get("projectDir"):
|
|
74
|
+
return True, None
|
|
75
|
+
|
|
76
|
+
f = _norm(abs_file_path)
|
|
77
|
+
|
|
78
|
+
if ctx.get("assignmentDir") and _is_under(f, ctx["assignmentDir"]):
|
|
79
|
+
return True, None
|
|
80
|
+
|
|
81
|
+
project_dir = ctx.get("projectDir")
|
|
82
|
+
if project_dir:
|
|
83
|
+
for sub in ("resources", "memories"):
|
|
84
|
+
d = os.path.join(project_dir, sub)
|
|
85
|
+
if _is_under(f, d) and not os.path.basename(f).startswith("_"):
|
|
86
|
+
return True, None
|
|
87
|
+
|
|
88
|
+
if context_file_abs and f == _norm(context_file_abs):
|
|
89
|
+
return True, None
|
|
90
|
+
|
|
91
|
+
if ctx.get("workspaceRoot") and _is_under(f, ctx["workspaceRoot"]):
|
|
92
|
+
return True, None
|
|
93
|
+
|
|
94
|
+
reason = (
|
|
95
|
+
"Syntaur write boundary violation: cannot write to '%s'. Allowed: assignment dir "
|
|
96
|
+
"(%s), project resources/memories, workspace (%s)."
|
|
97
|
+
% (f, ctx.get("assignmentDir") or "n/a", ctx.get("workspaceRoot") or "n/a")
|
|
98
|
+
)
|
|
99
|
+
return False, reason
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Syntaur extension for pi / OpenClaw (Tier-3)
|
|
2
|
+
|
|
3
|
+
A self-contained pi-coding-agent extension that brings Syntaur's Tier-3 enforcement parity (the same
|
|
4
|
+
behavior the Claude Code / Codex plugins have) to **pi** and **OpenClaw** (which runs on
|
|
5
|
+
pi-coding-agent):
|
|
6
|
+
|
|
7
|
+
- **Write-boundary enforcement** — a `tool_call` handler blocks edits/writes outside the active
|
|
8
|
+
assignment's boundaries (assignment dir, project `resources/`+`memories/` excluding derived `_*`
|
|
9
|
+
files, and the workspace root), mirroring `platforms/claude-code/hooks/enforce-boundaries.sh`.
|
|
10
|
+
- **Session cleanup** — a `session_shutdown` handler marks the dashboard session `stopped`.
|
|
11
|
+
- **Slash commands** — `doctor-syntaur` runs `syntaur doctor`; the rest (`grab-assignment`,
|
|
12
|
+
`log-progress`, `complete-assignment`, `save-session-summary`, `resume-session`, `set-workspace`,
|
|
13
|
+
`track-session`) point the agent at the installed Tier-1 skill of the same name.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
`syntaur setup --target pi` (or `--target openclaw`) copies this directory into the agent's extension
|
|
18
|
+
dir:
|
|
19
|
+
|
|
20
|
+
- pi → `~/.pi/agent/extensions/syntaur/`
|
|
21
|
+
- OpenClaw → `~/.openclaw/extensions/syntaur/`
|
|
22
|
+
|
|
23
|
+
pi auto-discovers `*/index.ts` extensions there (loaded via jiti). `syntaur doctor` reports Tier-3
|
|
24
|
+
install status.
|
|
25
|
+
|
|
26
|
+
## Notes & caveats
|
|
27
|
+
|
|
28
|
+
- **OpenClaw assumption:** per the cross-agent design memo, OpenClaw runs on `pi-coding-agent` and
|
|
29
|
+
loads the same extension format, so it reuses this exact source (only the install dir differs). If a
|
|
30
|
+
given OpenClaw build diverges to its own plugin system, only the install target needs repointing.
|
|
31
|
+
- The boundary decision logic (`isWriteAllowed`, `extractWritePath`, `loadContext`) is exported and
|
|
32
|
+
unit-tested in Syntaur's `src/__tests__/pi-extension.test.ts`.
|
|
33
|
+
- Fail-open: if there is no `.syntaur/context.json` or the tool call isn't a write, the extension
|
|
34
|
+
allows it — exactly like the bash hooks.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Syntaur Tier-3 enforcement extension for pi-coding-agent (and OpenClaw, which
|
|
2
|
+
// runs on pi). Mirrors the Claude Code / Codex bash hooks:
|
|
3
|
+
// - write-boundary enforcement (pi `tool_call` event → { block, reason })
|
|
4
|
+
// - session cleanup (pi `session_shutdown` event → mark session stopped)
|
|
5
|
+
// - Syntaur slash commands (pi `registerCommand`)
|
|
6
|
+
//
|
|
7
|
+
// SELF-CONTAINED: this file is shipped verbatim into the user's pi extensions dir
|
|
8
|
+
// (~/.pi/agent/extensions/syntaur/ or ~/.openclaw/extensions/syntaur/) and loaded by
|
|
9
|
+
// pi via jiti. It must NOT import from Syntaur's `src/`. The pure functions are
|
|
10
|
+
// exported so Syntaur's vitest suite can verify the boundary logic directly.
|
|
11
|
+
//
|
|
12
|
+
// pi extension API (researched): `export default (pi) => { pi.on(event, handler);
|
|
13
|
+
// pi.registerCommand(name, { description, handler }) }`. The `tool_call` handler
|
|
14
|
+
// returns `{ block: true, reason }` to DENY a tool call; any other return allows.
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
19
|
+
|
|
20
|
+
export interface SyntaurContext {
|
|
21
|
+
assignmentDir?: string;
|
|
22
|
+
projectDir?: string;
|
|
23
|
+
workspaceRoot?: string;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
projectSlug?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Expand a leading `~` to the home dir (the only expansion the bash hooks do). */
|
|
29
|
+
function expandHome(p: string): string {
|
|
30
|
+
if (p === '~') return homedir();
|
|
31
|
+
if (p.startsWith('~/')) return resolve(homedir(), p.slice(2));
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read `<cwd>/.syntaur/context.json`; null when absent/unparseable (fail-open). */
|
|
36
|
+
export function loadContext(cwd: string): SyntaurContext | null {
|
|
37
|
+
const file = resolve(cwd, '.syntaur', 'context.json');
|
|
38
|
+
let raw: string;
|
|
39
|
+
try {
|
|
40
|
+
raw = readFileSync(file, 'utf-8');
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
let data: Record<string, unknown>;
|
|
45
|
+
try {
|
|
46
|
+
data = JSON.parse(raw) as Record<string, unknown>;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const str = (v: unknown): string | undefined =>
|
|
51
|
+
typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
52
|
+
const assignmentDir = str(data.assignmentDir);
|
|
53
|
+
const projectDir = str(data.projectDir);
|
|
54
|
+
const workspaceRoot = str(data.workspaceRoot);
|
|
55
|
+
return {
|
|
56
|
+
assignmentDir: assignmentDir ? expandHome(assignmentDir) : undefined,
|
|
57
|
+
projectDir: projectDir ? expandHome(projectDir) : undefined,
|
|
58
|
+
workspaceRoot: workspaceRoot ? expandHome(workspaceRoot) : undefined,
|
|
59
|
+
sessionId: str(data.sessionId),
|
|
60
|
+
projectSlug: str(data.projectSlug),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True if `child` is strictly under `parent` (mirrors the bash `"$X"/*` test). */
|
|
65
|
+
function isUnder(child: string, parent: string): boolean {
|
|
66
|
+
if (!parent) return false;
|
|
67
|
+
const c = resolve(child);
|
|
68
|
+
const p = resolve(parent);
|
|
69
|
+
return c.startsWith(p.endsWith(sep) ? p : p + sep);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function basename(p: string): string {
|
|
73
|
+
const norm = resolve(p);
|
|
74
|
+
const idx = norm.lastIndexOf(sep);
|
|
75
|
+
return idx >= 0 ? norm.slice(idx + 1) : norm;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decide whether a write to `absFilePath` is allowed under the active assignment.
|
|
80
|
+
* Mirrors `platforms/claude-code/hooks/enforce-boundaries.sh` exactly:
|
|
81
|
+
* - allow under assignmentDir
|
|
82
|
+
* - allow under projectDir/resources/ and projectDir/memories/ EXCEPT derived `_*` files
|
|
83
|
+
* - allow the `.syntaur/context.json` file itself (caller passes cwd-resolved path)
|
|
84
|
+
* - allow under workspaceRoot (if set)
|
|
85
|
+
* - otherwise block
|
|
86
|
+
*/
|
|
87
|
+
export function isWriteAllowed(
|
|
88
|
+
absFilePath: string,
|
|
89
|
+
ctx: SyntaurContext,
|
|
90
|
+
contextFileAbs?: string,
|
|
91
|
+
): { allowed: boolean; reason?: string } {
|
|
92
|
+
// Parity with the bash hook (enforce-boundaries.sh:69): enforcement requires
|
|
93
|
+
// BOTH assignmentDir and projectDir. If either is missing (standalone / old /
|
|
94
|
+
// partially-written context), fail OPEN — allow everything.
|
|
95
|
+
if (!ctx.assignmentDir || !ctx.projectDir) return { allowed: true };
|
|
96
|
+
|
|
97
|
+
const file = resolve(absFilePath);
|
|
98
|
+
|
|
99
|
+
if (ctx.assignmentDir && isUnder(file, ctx.assignmentDir)) return { allowed: true };
|
|
100
|
+
|
|
101
|
+
if (ctx.projectDir) {
|
|
102
|
+
const resourcesDir = resolve(ctx.projectDir, 'resources');
|
|
103
|
+
const memoriesDir = resolve(ctx.projectDir, 'memories');
|
|
104
|
+
for (const dir of [resourcesDir, memoriesDir]) {
|
|
105
|
+
if (isUnder(file, dir) && !basename(file).startsWith('_')) return { allowed: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (contextFileAbs && file === resolve(contextFileAbs)) return { allowed: true };
|
|
110
|
+
|
|
111
|
+
if (ctx.workspaceRoot && isUnder(file, ctx.workspaceRoot)) return { allowed: true };
|
|
112
|
+
|
|
113
|
+
const reason =
|
|
114
|
+
`Syntaur write boundary violation: cannot write to '${file}'. Allowed: assignment dir ` +
|
|
115
|
+
`(${ctx.assignmentDir ?? 'n/a'}), project resources/memories, workspace ` +
|
|
116
|
+
`(${ctx.workspaceRoot ?? 'n/a'}).`;
|
|
117
|
+
return { allowed: false, reason };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** pi write-ish tool names (lowercase dialect), matched case-insensitively. */
|
|
121
|
+
export const WRITE_TOOLS: ReadonlySet<string> = new Set([
|
|
122
|
+
'edit',
|
|
123
|
+
'write',
|
|
124
|
+
'multi_edit',
|
|
125
|
+
'multiedit',
|
|
126
|
+
'create',
|
|
127
|
+
'create_file',
|
|
128
|
+
'str_replace',
|
|
129
|
+
'str_replace_editor',
|
|
130
|
+
'apply_patch',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const PATH_KEYS = ['file_path', 'path', 'filePath', 'filename', 'target_file'];
|
|
134
|
+
|
|
135
|
+
/** Extract the write target path from a tool call; null if not a write / no path. */
|
|
136
|
+
export function extractWritePath(toolName: unknown, input: unknown): string | null {
|
|
137
|
+
if (typeof toolName !== 'string') return null;
|
|
138
|
+
if (!WRITE_TOOLS.has(toolName.toLowerCase())) return null;
|
|
139
|
+
if (typeof input !== 'object' || input === null) return null;
|
|
140
|
+
const obj = input as Record<string, unknown>;
|
|
141
|
+
for (const k of PATH_KEYS) {
|
|
142
|
+
const v = obj[k];
|
|
143
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveAbs(p: string, cwd: string): string {
|
|
149
|
+
return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CoreCommand {
|
|
153
|
+
name: string;
|
|
154
|
+
description: string;
|
|
155
|
+
kind: 'passthrough' | 'guidance';
|
|
156
|
+
/** For `passthrough`: argv passed to the `syntaur` CLI. */
|
|
157
|
+
argv?: string[];
|
|
158
|
+
/** For `guidance`: the installed Tier-1 skill the agent should follow. */
|
|
159
|
+
skill?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Slash commands match the existing CC/Codex bare command names for parity. Only
|
|
163
|
+
// `doctor-syntaur` shells out (no required args); the rest point the agent at the
|
|
164
|
+
// installed Tier-1 skill, which derives assignment/session from .syntaur/context.json.
|
|
165
|
+
export const CORE_COMMANDS: CoreCommand[] = [
|
|
166
|
+
{ name: 'doctor-syntaur', description: 'Run `syntaur doctor` diagnostics', kind: 'passthrough', argv: ['doctor'] },
|
|
167
|
+
{ name: 'grab-assignment', description: 'Claim a Syntaur assignment into this session', kind: 'guidance', skill: 'grab-assignment' },
|
|
168
|
+
{ name: 'log-progress', description: 'Append a progress entry to the active assignment', kind: 'guidance', skill: 'log-progress' },
|
|
169
|
+
{ name: 'complete-assignment', description: 'Write a handoff and complete the assignment', kind: 'guidance', skill: 'complete-assignment' },
|
|
170
|
+
{ name: 'save-session-summary', description: 'Save a session continuity summary', kind: 'guidance', skill: 'save-session-summary' },
|
|
171
|
+
{ name: 'resume-session', description: 'Re-orient on the active assignment', kind: 'guidance', skill: 'resume-session' },
|
|
172
|
+
{ name: 'set-workspace', description: 'Set workspace fields on the active assignment', kind: 'guidance', skill: 'set-workspace' },
|
|
173
|
+
{ name: 'track-session', description: 'Register this session in the Syntaur dashboard', kind: 'guidance', skill: 'track-session' },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
function dashboardPort(): string {
|
|
177
|
+
const env = process.env.SYNTAUR_DASHBOARD_PORT;
|
|
178
|
+
if (env && env.length > 0) return env;
|
|
179
|
+
try {
|
|
180
|
+
return readFileSync(resolve(homedir(), '.syntaur', 'dashboard-port'), 'utf-8').trim() || '4800';
|
|
181
|
+
} catch {
|
|
182
|
+
return '4800';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Mark the dashboard session stopped (best-effort, swallow every error). */
|
|
187
|
+
export async function markSessionStopped(ctx: SyntaurContext | null): Promise<void> {
|
|
188
|
+
if (!ctx?.sessionId) return;
|
|
189
|
+
const body = JSON.stringify(
|
|
190
|
+
ctx.projectSlug ? { status: 'stopped', projectSlug: ctx.projectSlug } : { status: 'stopped' },
|
|
191
|
+
);
|
|
192
|
+
try {
|
|
193
|
+
await fetch(`http://127.0.0.1:${dashboardPort()}/api/agent-sessions/${ctx.sessionId}/status`, {
|
|
194
|
+
method: 'PATCH',
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body,
|
|
197
|
+
signal: AbortSignal.timeout(3000),
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
/* dashboard not running / unreachable — ignore */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function notify(ctx: unknown, message: string, level: 'info' | 'error' = 'info'): void {
|
|
205
|
+
const ui = (ctx as { ui?: { notify?: (m: string, l?: string) => void } } | undefined)?.ui;
|
|
206
|
+
if (ui?.notify) {
|
|
207
|
+
try {
|
|
208
|
+
ui.notify(message, level);
|
|
209
|
+
return;
|
|
210
|
+
} catch {
|
|
211
|
+
/* fall through to console */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// eslint-disable-next-line no-console
|
|
215
|
+
console.log(message);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** pi/OpenClaw extension entry point. */
|
|
219
|
+
export default function activate(pi: {
|
|
220
|
+
on: (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
|
|
221
|
+
registerCommand: (
|
|
222
|
+
name: string,
|
|
223
|
+
spec: { description: string; handler: (args: string, ctx: unknown) => unknown },
|
|
224
|
+
) => void;
|
|
225
|
+
}): void {
|
|
226
|
+
// --- write-boundary enforcement ---
|
|
227
|
+
pi.on('tool_call', (event: unknown) => {
|
|
228
|
+
const e = (event ?? {}) as { toolName?: unknown; input?: unknown };
|
|
229
|
+
const path = extractWritePath(e.toolName, e.input);
|
|
230
|
+
if (!path) return; // not a write → allow
|
|
231
|
+
const cwd = process.cwd();
|
|
232
|
+
const ctx = loadContext(cwd);
|
|
233
|
+
if (!ctx) return; // no active assignment → allow
|
|
234
|
+
const abs = resolveAbs(path, cwd);
|
|
235
|
+
const contextFileAbs = resolve(cwd, '.syntaur', 'context.json');
|
|
236
|
+
const { allowed, reason } = isWriteAllowed(abs, ctx, contextFileAbs);
|
|
237
|
+
if (!allowed) return { block: true, reason };
|
|
238
|
+
return undefined;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// --- session cleanup ---
|
|
242
|
+
pi.on('session_shutdown', async () => {
|
|
243
|
+
await markSessionStopped(loadContext(process.cwd()));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- slash commands ---
|
|
247
|
+
for (const cmd of CORE_COMMANDS) {
|
|
248
|
+
pi.registerCommand(cmd.name, {
|
|
249
|
+
description: cmd.description,
|
|
250
|
+
handler: async (_args: string, ctx: unknown) => {
|
|
251
|
+
if (cmd.kind === 'passthrough' && cmd.argv) {
|
|
252
|
+
const { spawnSync } = await import('node:child_process');
|
|
253
|
+
const r = spawnSync('syntaur', cmd.argv, { encoding: 'utf-8' });
|
|
254
|
+
notify(ctx, (r.stdout || '') + (r.stderr || '') || `ran: syntaur ${cmd.argv.join(' ')}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
notify(
|
|
258
|
+
ctx,
|
|
259
|
+
`Follow the Syntaur "${cmd.skill}" skill (installed via skills). It derives the active ` +
|
|
260
|
+
`assignment/session from .syntaur/context.json — run its steps to ${cmd.description.toLowerCase()}.`,
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|