mirror of
https://github.com/Heretek-AI/heretek-rigs.git
synced 2026-11-07 10:55:57 -10:00
1492 lines
60 KiB
Python
Executable File
1492 lines
60 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""render-seat.py — Render one Pi seat's isolated agentDir for OpenRig + OpenCode Go.
|
|
|
|
Renders into $OPENRIG_HOME/state/pi/<pod>-<member>@<rig>/agent/ (all files mode 0600):
|
|
1. auth.json: command-backed API key ("!cat <SECRETS>/opencode-go.key"), never a literal secret.
|
|
2. models.json: OpenCode Go provider definition (api: openai-responses, model: muse-spark-1.3-contributor).
|
|
3. mcp.json: Pi built-in MCP config from fleet/mcp-servers.yaml with @TOOLS@, @SECRETS@, @SEAT@ resolved.
|
|
4. settings.json: defaultProvider, defaultModel, defaultThinkingLevel, defaultTools, packages, quietStartup.
|
|
5. extensions/pi-permission-system/config.json: fail-closed @gotgenes/[email protected] rules with
|
|
mandatory "*..*": "deny" placed LAST in path_write and path, read-only AST/LSP tools allowed where configured,
|
|
and mutating AST/LSP tools (code_rewrite, lsp_rename, lsp_code_actions) explicitly denied.
|
|
6. AGENTS.md: Role charter from specs/agents/<domain>/<role>/guidance/role.md plus seat address & escalation block
|
|
(avoiding OpenRig's shared-<cwd> guidance_merge collision).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
if shutil.which("uv") and not os.environ.get("_HERETEK_UV_REEXEC"):
|
|
os.environ["_HERETEK_UV_REEXEC"] = "1"
|
|
os.execvp(
|
|
"uv",
|
|
[
|
|
"uv",
|
|
"run",
|
|
"--quiet",
|
|
"--with",
|
|
"pyyaml",
|
|
"--with",
|
|
"jsonschema",
|
|
"python3",
|
|
*sys.argv,
|
|
],
|
|
)
|
|
raise
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shlex
|
|
from pathlib import Path
|
|
|
|
USAGE = """usage: render-seat.py --role <role-id> --rig <rig-id> --pod <pod-id> [options]
|
|
--member <id> member id in the rig (default: the role id)
|
|
--fleet <file> default fleet/fleet.yaml
|
|
--mcp <file> default fleet/mcp-servers.yaml
|
|
--charter <file> default specs/agents/<domain>/<role>/guidance/role.md
|
|
--out <dir> default $OPENRIG_HOME/state/pi/<pod>-<member>@<rig>/agent
|
|
--model-provider <p> default opencode-go (tests use: mock)
|
|
--model-id <id> default muse-spark-1.3-contributor (tests use: mock-model)
|
|
--allow-missing-secrets write servers whose secret files are missing with "enabled": false
|
|
--dry-run print the rendered files as JSON instead of writing them
|
|
--policy-only rewrite ONLY extensions/pi-permission-system/ (config.json and
|
|
its audit sidecar) in an existing seat dir, atomically; pi-permission-system
|
|
re-reads it on mtime change, so a running seat picks it
|
|
up on its next tool call without a relaunch (#96)
|
|
--help"""
|
|
|
|
READONLY_AST_LSP_TOOLS = [
|
|
"ast_grep",
|
|
"ast_search",
|
|
"code_overview",
|
|
"lsp_diagnostics",
|
|
"lsp_hover",
|
|
"lsp_definition",
|
|
"lsp_references",
|
|
"lsp_symbols",
|
|
"lsp_completions",
|
|
]
|
|
|
|
MUTATING_AST_LSP_TOOLS = [
|
|
"code_rewrite",
|
|
"lsp_rename",
|
|
"lsp_code_actions",
|
|
]
|
|
|
|
# S2a/M9 (#69, #72): single source of truth for the terminal bash denies
|
|
# emitted LAST into every seat's bash map (last-match-wins) and mirrored in
|
|
# scripts/verify-rigs.sh `_TERMINAL_BASH_DENIES`. verify-rigs V13 asserts the
|
|
# two tuples are ordered-identical, so weakening either side flips the gate.
|
|
TERMINAL_BASH_DENIES = (
|
|
"*--output=*",
|
|
"*--output *",
|
|
"*--output\t*",
|
|
"*--output-junit*",
|
|
"*--output-log*",
|
|
"*;*",
|
|
"*|*",
|
|
"*>*",
|
|
"*<*",
|
|
"*&*",
|
|
"*`*",
|
|
"*$*",
|
|
"*(*",
|
|
"*)*",
|
|
"* -p*",
|
|
"*\t-p*",
|
|
"*--patch*",
|
|
"*--full-diff*",
|
|
"*--textconv*",
|
|
# M5 (#69) git-diff read-bypass family: -u/-U/--unified/--cc and
|
|
# the spaced -c/-m/-W flag forms plus git's word/binary/raw
|
|
# diff-output flags (same last-match-wins rationale as -p).
|
|
"* -u*",
|
|
"*\t-u*",
|
|
"* -U*",
|
|
"*\t-U*",
|
|
"*--unified*",
|
|
"*--cc*",
|
|
"* -c *",
|
|
"*\t-c *",
|
|
"* -c\t*",
|
|
"*\t-c\t*",
|
|
"* -m *",
|
|
"*\t-m *",
|
|
"* -m\t*",
|
|
"*\t-m\t*",
|
|
"* -W*",
|
|
"*\t-W*",
|
|
"*--function-context*",
|
|
"*--word-diff*",
|
|
"*--color-words*",
|
|
"*--binary*",
|
|
"*--raw*",
|
|
# M5 (#72/#93) identity-forgery forms: a leading VAR= assignment is
|
|
# stripped by pps before matching (F9), so this catches only the
|
|
# `env VAR=...` form at the policy layer; the leading-assignment
|
|
# form is closed in scripts/send-authenticated.sh instead.
|
|
"*OPENRIG_SESSION_NAME=*",
|
|
"*OPENRIG_URL=*",
|
|
)
|
|
|
|
|
|
# #106 commandEffects overlay (REQ-C1/C2): declarative allow-effects for two
|
|
# `rig queue` argv positions. The terminal bash denies above stay frozen
|
|
# (REQ-G1) — the overlay scopes the exception to these argv positions and
|
|
# never loosens a deny. Scope roots stay cwd-relative descriptors (never
|
|
# baked absolute host paths: the #67 worktree-stale lesson). Enforcement
|
|
# points re-check at use time; every error denies (fail-closed, REQ-C3).
|
|
NOTE_VALUE_PATTERN = r"^[A-Za-z0-9 _\-/.:|]+$"
|
|
NOTE_VALUE_RE = re.compile(NOTE_VALUE_PATTERN)
|
|
BODY_FILE_MAX_BYTES = 262144
|
|
BODY_FILE_SCRATCH_RE = re.compile(r"^/var/tmp/seatjail\.\d+(?:/.*)?$")
|
|
NOTE_VALUE_DENY_TOKENS = (
|
|
";",
|
|
"$",
|
|
"`",
|
|
"$(",
|
|
"OPENRIG_SESSION_NAME=",
|
|
"OPENRIG_URL=",
|
|
)
|
|
|
|
|
|
def is_note_value_allowed(value) -> bool:
|
|
"""REQ-C1: `--note` single-argv value allows literal `| / . - _ :` plus
|
|
alphanumerics and space, never as shell control (argv, not `sh -c`)."""
|
|
if not isinstance(value, str) or not value:
|
|
return False
|
|
if "\n" in value or "\r" in value:
|
|
return False
|
|
for tok in NOTE_VALUE_DENY_TOKENS:
|
|
if tok in value:
|
|
return False
|
|
return NOTE_VALUE_RE.fullmatch(value) is not None
|
|
|
|
|
|
def _expanded_scope_roots(cwd_resolved: str, path_write) -> list:
|
|
"""REQ-C2 scope roots: realpath(cwd) plus each path_write entry's
|
|
literal base resolved under cwd. Glob-only entries collapse onto the
|
|
cwd root; `..`/absolute entries are skipped (fail-closed)."""
|
|
roots = []
|
|
if cwd_resolved:
|
|
roots.append(cwd_resolved.rstrip("/") or "/")
|
|
for entry in path_write or []:
|
|
e = str(entry)
|
|
if e.startswith("/") or ".." in e.split("/"):
|
|
continue
|
|
base = e
|
|
cut = len(base)
|
|
for i, ch in enumerate(base):
|
|
if ch in "*?[":
|
|
cut = i
|
|
break
|
|
base = base[:cut].rstrip("/")
|
|
if "/" in base:
|
|
base = base.rsplit("/", 1)[0]
|
|
else:
|
|
base = ""
|
|
if not base:
|
|
continue
|
|
cand = os.path.normpath(os.path.join(cwd_resolved, base))
|
|
if cand not in roots:
|
|
roots.append(cand)
|
|
return roots
|
|
|
|
|
|
def classify_body_file(
|
|
raw_path: str,
|
|
*,
|
|
cwd_resolved: str,
|
|
expanded_roots: list,
|
|
is_regular_file: bool,
|
|
size_bytes,
|
|
resolved_path,
|
|
) -> tuple:
|
|
"""REQ-C2/C3 pure verdict: (verdict, reason) with verdict in
|
|
{"allow-effect", "deny"}. Scope-resolution error, missing file,
|
|
oversize, symlink escape, `..` escape, and absolute /tmp all deny."""
|
|
if not raw_path or not resolved_path:
|
|
return ("deny", "scope-resolution error or missing file")
|
|
if not is_regular_file:
|
|
return ("deny", "not a regular file")
|
|
if size_bytes is None or size_bytes > BODY_FILE_MAX_BYTES:
|
|
return ("deny", "oversize or unknown size (limit 256KiB)")
|
|
resolved = os.path.normpath(resolved_path)
|
|
if BODY_FILE_SCRATCH_RE.match(resolved):
|
|
return ("allow-effect", "pid-namespaced scratch scope")
|
|
for root in expanded_roots or []:
|
|
r = os.path.normpath(root)
|
|
if resolved == r or resolved.startswith(r.rstrip("/") + "/"):
|
|
return ("allow-effect", "in-scope root")
|
|
if resolved == "/tmp" or resolved.startswith("/tmp/"):
|
|
return ("deny", "absolute /tmp path outside scope")
|
|
return ("deny", "outside own scope")
|
|
|
|
|
|
def check_body_file_path(raw_path: str, *, cwd: str, path_write) -> tuple:
|
|
"""REQ-C2 filesystem check (fail-closed): realpath + regular-file +
|
|
size + scope. Best-effort at check time; enforcement rechecks at use."""
|
|
import stat as _stat
|
|
|
|
if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
|
|
return ("deny", "empty or invalid path")
|
|
try:
|
|
cwd_resolved = os.path.realpath(cwd)
|
|
except OSError:
|
|
return ("deny", "scope-resolution error (cwd)")
|
|
try:
|
|
resolved = os.path.realpath(raw_path)
|
|
except OSError:
|
|
return ("deny", "scope-resolution error or missing file")
|
|
try:
|
|
st = os.stat(resolved)
|
|
except OSError:
|
|
return ("deny", "scope-resolution error or missing file")
|
|
roots = _expanded_scope_roots(cwd_resolved, path_write)
|
|
return classify_body_file(
|
|
raw_path,
|
|
cwd_resolved=cwd_resolved,
|
|
expanded_roots=roots,
|
|
is_regular_file=_stat.S_ISREG(st.st_mode),
|
|
size_bytes=st.st_size,
|
|
resolved_path=resolved,
|
|
)
|
|
|
|
|
|
def build_command_effects(path_write, role_verbs=None) -> list:
|
|
"""REQ-C1/C2 declarative overlay rendered into every seat's permission
|
|
output. Role-specific write roots ride along as descriptors only.
|
|
#91: with role_verbs (the role's bash_allow), per-tool scoped-arg
|
|
descriptors (pytest/go/cmake/gcc) are appended for held verbs only;
|
|
without verbs the list stays note + body-file (V15 pin)."""
|
|
effects = [
|
|
{
|
|
"id": "note-effect",
|
|
"argv": ["rig", "queue", "--note"],
|
|
"match": {
|
|
"position": "single-argv-value",
|
|
"pattern": NOTE_VALUE_PATTERN,
|
|
"execModel": "argv-not-shell",
|
|
},
|
|
"covers": [
|
|
"rig queue create --note",
|
|
"rig queue block --note",
|
|
"rig queue update --reopen --note",
|
|
"park-typed-block.py relay",
|
|
"sweep-typed-blocks.py relay",
|
|
],
|
|
"denyWhen": list(NOTE_VALUE_DENY_TOKENS) + ["newline", "carriage-return"],
|
|
"effect": "allow-effect",
|
|
},
|
|
{
|
|
"id": "body-file-effect",
|
|
"argv": ["rig", "queue", "--body-file"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
"scratch": "/var/tmp/seatjail.<pid>.*",
|
|
},
|
|
"constraints": {
|
|
"regularFileOnly": True,
|
|
"maxBytes": BODY_FILE_MAX_BYTES,
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": [
|
|
"rig queue create --body-file",
|
|
"send-authenticated.sh --source staged relay",
|
|
],
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
},
|
|
]
|
|
if role_verbs:
|
|
_binaries = set()
|
|
for _pat in role_verbs:
|
|
_bin = bash_leading_binary(str(_pat))
|
|
if _bin is not None:
|
|
_binaries.add(_bin)
|
|
if "pytest" in _binaries:
|
|
effects.append(
|
|
{
|
|
"id": "pytest-effect",
|
|
"argv": ["pytest", "<tests/rag-operand>"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
},
|
|
"constraints": {
|
|
"scopedOperandOnly": True,
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": ["pytest tests/rag"],
|
|
"native": {"pytest": {"effects": "read"}},
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
}
|
|
)
|
|
if "go" in _binaries:
|
|
effects.append(
|
|
{
|
|
"id": "go-effect",
|
|
"argv": ["go", "<build|test>", "<package-pattern-or-path>"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
},
|
|
"constraints": {
|
|
"packagePatternBranch": True,
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": ["go build ./...", "go build .", "go test ./..."],
|
|
"native": {
|
|
"go": {
|
|
"unlessOption": ["-o"],
|
|
"subcommands": {"build": "read", "test": "read"},
|
|
}
|
|
},
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
}
|
|
)
|
|
if "cmake" in _binaries:
|
|
effects.append(
|
|
{
|
|
"id": "cmake-effect",
|
|
"argv": ["cmake", "-S", "<source-dir>", "-B", "<build-dir>"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
},
|
|
"constraints": {
|
|
"flagValuePairing": ["-S", "-B"],
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": ["cmake -S . -B build"],
|
|
"native": {"cmake": {"effects": "read"}},
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
}
|
|
)
|
|
if "gcc" in _binaries or "g++" in _binaries:
|
|
effects.append(
|
|
{
|
|
"id": "gcc-effect",
|
|
"argv": ["gcc|g++", "-c", "<source>", "-o", "<object>"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
},
|
|
"constraints": {
|
|
"compileSlotOnly": True,
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": ["gcc -c src/foo.c", "g++ -c src/foo.cpp"],
|
|
"overlay": "after-terminal-denies",
|
|
"native": {
|
|
"gcc": {"effects": "read", "unlessOption": ["-o"]},
|
|
"g++": {"effects": "read", "unlessOption": ["-o"]},
|
|
},
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
}
|
|
)
|
|
if "ctest" in _binaries:
|
|
effects.append(
|
|
{
|
|
"id": "ctest-effect",
|
|
"argv": ["ctest", "<--test-dir>", "<build-dir>"],
|
|
"scope": {
|
|
"cwd": "realpath(CWD)",
|
|
"writeRootsSource": "permissions.path_write",
|
|
"writeRoots": list(path_write or []),
|
|
},
|
|
"constraints": {
|
|
"testDirOnly": True,
|
|
"noScriptMode": True,
|
|
"noSymlinkEscape": True,
|
|
"noDotDotEscape": True,
|
|
"noAbsoluteTmp": True,
|
|
},
|
|
"covers": ["ctest --test-dir build --output-on-failure"],
|
|
"native": {
|
|
"ctest": {
|
|
"effects": "read",
|
|
"unlessOption": [
|
|
"-S",
|
|
"--script",
|
|
"--script-new-process",
|
|
],
|
|
}
|
|
},
|
|
"effect": "allow-effect-if-in-scope-else-deny",
|
|
}
|
|
)
|
|
return effects
|
|
|
|
|
|
# #91 scoped build/test path args (REQ-A1/A2): native-map declarations (the
|
|
# M0 winner — the descriptor list form is unparsed by installed pps 41.0.0
|
|
# and fails the whole permission layer closed to ask) plus the audit-side
|
|
# classifiers below. Enforcement lives in pps (map + path surfaces); the
|
|
# descriptors above document the argv slots for review and red-proofs.
|
|
def build_command_effects_map(bash_allow) -> dict:
|
|
"""REQ-A1 native `commandEffects` map keyed by bare basename (pps
|
|
config-schema: record of command → entry). Only tools whose verbs the
|
|
role holds are declared; anything else stays unproven (fail-closed)."""
|
|
binaries = set()
|
|
for _pat in bash_allow or []:
|
|
_bin = bash_leading_binary(str(_pat))
|
|
if _bin is not None:
|
|
binaries.add(_bin)
|
|
declared = {}
|
|
if "pytest" in binaries:
|
|
declared["pytest"] = {"effects": "read"}
|
|
if "go" in binaries:
|
|
declared["go"] = {
|
|
"unlessOption": ["-o"],
|
|
"subcommands": {"build": "read", "test": "read"},
|
|
}
|
|
if "cmake" in binaries:
|
|
declared["cmake"] = {"effects": "read"}
|
|
if "gcc" in binaries:
|
|
declared["gcc"] = {"effects": "read", "unlessOption": ["-o"]}
|
|
if "g++" in binaries:
|
|
declared["g++"] = {"effects": "read", "unlessOption": ["-o"]}
|
|
if "ctest" in binaries:
|
|
declared["ctest"] = {
|
|
"effects": "read",
|
|
"unlessOption": ["-S", "--script", "--script-new-process"],
|
|
}
|
|
return declared
|
|
|
|
|
|
# Shell-control tokens rejected in scoped path values — the same token set
|
|
# as the note-effect deny list (REQ-A1), plus newline/carriage-return.
|
|
def _scoped_value_has_shell_token(raw: str) -> bool:
|
|
if "\n" in raw or "\r" in raw:
|
|
return True
|
|
return any(tok in raw for tok in NOTE_VALUE_DENY_TOKENS)
|
|
|
|
|
|
def classify_scoped_path_value(raw_path: str, *, cwd: str, path_write) -> tuple:
|
|
"""REQ-A1 shared flag-value path check: realpath (relative to cwd) must
|
|
stay inside _expanded_scope_roots; shell tokens, NUL, and resolution
|
|
failures deny. No existence/regular-file/size requirements (unlike the
|
|
body-file slot): build dirs and outputs need not exist yet."""
|
|
if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
|
|
return ("deny", "empty or invalid path")
|
|
if _scoped_value_has_shell_token(raw_path):
|
|
return ("deny", "shell-control token in path value")
|
|
try:
|
|
cwd_resolved = os.path.realpath(cwd)
|
|
except OSError:
|
|
return ("deny", "scope-resolution error (cwd)")
|
|
try:
|
|
if os.path.isabs(raw_path):
|
|
resolved = os.path.realpath(raw_path)
|
|
else:
|
|
resolved = os.path.realpath(os.path.join(cwd_resolved, raw_path))
|
|
except OSError:
|
|
return ("deny", "scope-resolution error")
|
|
roots = _expanded_scope_roots(cwd_resolved, path_write)
|
|
resolved_norm = os.path.normpath(resolved)
|
|
for root in roots:
|
|
r = os.path.normpath(root)
|
|
if resolved_norm == r or resolved_norm.startswith(r.rstrip("/") + "/"):
|
|
return ("allow-effect", "in-scope root")
|
|
if resolved_norm == "/tmp" or resolved_norm.startswith("/tmp/"):
|
|
return ("deny", "absolute /tmp path outside scope")
|
|
return ("deny", "outside own scope")
|
|
|
|
|
|
def classify_go_package_arg(arg: str) -> tuple:
|
|
"""REQ-A1 Go `...` package-pattern branch: `...`-suffixed args are
|
|
package patterns within scope, never literal paths — so `./...` allows
|
|
while `../../etc/...` denies. Any `..` segment outside the trailing
|
|
`...` itself, absolute forms, and shell tokens deny first."""
|
|
if not isinstance(arg, str) or not arg:
|
|
return ("deny", "empty or invalid arg")
|
|
if _scoped_value_has_shell_token(arg) or "\x00" in arg:
|
|
return ("deny", "shell-control token in arg")
|
|
if not arg.endswith("..."):
|
|
return ("not-a-pattern", "not a ...-suffixed package pattern")
|
|
stem = arg[:-3]
|
|
if stem.startswith("/"):
|
|
return ("deny", "absolute package pattern outside scope")
|
|
for seg in stem.split("/"):
|
|
if seg == "..":
|
|
return ("deny", "dot-dot escape in package pattern")
|
|
return ("allow-effect", "in-scope package pattern")
|
|
|
|
|
|
def pair_cmake_flag_values(argv) -> list:
|
|
"""REQ-A1 cmake -S/-B flag-value pairing. Returns [(flag, value)] with
|
|
value None for a dangling flag. Supports separate (`-S .`) and attached
|
|
(`-S.`) spellings; a following -S/-B starts a new pair (fail-closed)."""
|
|
pairs = []
|
|
words = list(argv[1:] if argv else [])
|
|
i = 0
|
|
while i < len(words):
|
|
w = words[i]
|
|
flag = None
|
|
inline = None
|
|
if w in ("-S", "-B"):
|
|
flag = w
|
|
elif w.startswith("-S") and len(w) > 2:
|
|
flag, inline = "-S", w[2:]
|
|
elif w.startswith("-B") and len(w) > 2:
|
|
flag, inline = "-B", w[2:]
|
|
if flag is None:
|
|
i += 1
|
|
continue
|
|
if inline is not None:
|
|
pairs.append((flag, inline))
|
|
i += 1
|
|
continue
|
|
nxt = words[i + 1] if i + 1 < len(words) else None
|
|
if nxt is None or nxt in ("-S", "-B") or nxt.startswith("-"):
|
|
pairs.append((flag, None))
|
|
i += 1
|
|
else:
|
|
pairs.append((flag, nxt))
|
|
i += 2
|
|
return pairs
|
|
|
|
|
|
def classify_gcc_command(argv, *, cwd: str, path_write) -> tuple:
|
|
"""REQ-A1/A2 gcc compile-slot check: head must be the granted gcc
|
|
family; every non-flag operand plus -o/-I/-isystem/-L values must
|
|
resolve in scope. -D/-l/-std/-x-style flag values are not paths and
|
|
are skipped. --output forms deny (defense in depth: the overlay
|
|
allow sits after the terminal --output denies, so the classifier
|
|
stays stricter than enforcement)."""
|
|
words = list(argv or [])
|
|
if not words:
|
|
return ("deny", "empty argv")
|
|
for w in words:
|
|
if not isinstance(w, str) or "\x00" in w:
|
|
return ("deny", "invalid argv word")
|
|
if _scoped_value_has_shell_token(w):
|
|
return ("deny", "shell-control token in argv")
|
|
if "--output" in w:
|
|
return ("deny", "forbidden --output write-escape")
|
|
head = os.path.basename(words[0])
|
|
if head not in ("gcc", "g++"):
|
|
return ("deny", "not a granted compiler head word")
|
|
operands = []
|
|
i = 1
|
|
while i < len(words):
|
|
w = words[i]
|
|
if w in ("-o", "-I", "-isystem", "-L"):
|
|
nxt = words[i + 1] if i + 1 < len(words) else None
|
|
if nxt is None or nxt.startswith("-"):
|
|
return ("deny", f"dangling {w} value")
|
|
operands.append(nxt)
|
|
i += 2
|
|
continue
|
|
if w.startswith("-"):
|
|
i += 1
|
|
continue
|
|
operands.append(w)
|
|
i += 1
|
|
if not operands:
|
|
return ("deny", "no source operands")
|
|
for op in operands:
|
|
verdict, _ = classify_scoped_path_value(op, cwd=cwd, path_write=path_write)
|
|
if verdict != "allow-effect":
|
|
return ("deny", f"operand outside scope: {op}")
|
|
return ("allow-effect", "in-scope compile operands: " + " ".join(operands))
|
|
|
|
|
|
def _sandbox_bind_roots(path_write):
|
|
"""Map pps path_write globs to jail write roots (M4, #68; #105 review).
|
|
|
|
The bubblewrap wrapper (scripts/seat-sandbox.sh) reads two entry kinds:
|
|
a pure directory grant (`src/*`, `tests/chaos/*`) becomes `src/`, which
|
|
is bound read-write; anything else (`package.json`, `docs/*.md`,
|
|
`*package-lock.json`) stays a file pattern, and only EXISTING regular
|
|
files matching it are bound in place. Nothing ever maps to the whole
|
|
seat cwd. pi-permission-system remains the fine-grained gate; the jail
|
|
adds cwd-containment, no network, and masked secret stores.
|
|
"""
|
|
roots = []
|
|
for entry in path_write or []:
|
|
e = entry
|
|
if e.endswith("/*") and not any(c in e[:-2] for c in "*?["):
|
|
e = e[:-2].rstrip("/") + "/"
|
|
if e in ("", "/", "./"):
|
|
continue
|
|
if e not in roots:
|
|
roots.append(e)
|
|
return roots
|
|
|
|
|
|
# 107-identity M1 (REQ-I1): per-seat MCP-over-HTTP bearer grants.
|
|
# The catalog server `rig-mcp-http` carries `seat_grant: true` and NO static
|
|
# env/headers (the V6 shape-check stays vacuous). At render time the seat's
|
|
# grant token file (seed-issued, 0600, secrets dir) is wired as the existing
|
|
# `!echo Bearer $(cat ...)` Authorization form — never a third substitution
|
|
# form. The `.key` suffix reuses the frozen `*.key` path deny, so no seat can
|
|
# read another seat's grant through the permission layer.
|
|
SEAT_GRANT_FLAG = "seat_grant"
|
|
|
|
|
|
def seat_grant_token_filename(session: str) -> str:
|
|
"""Secrets-dir filename for a seat's MCP HTTP bearer grant.
|
|
|
|
The session is embedded verbatim (grant names accept `@`; render dies on
|
|
`.`/`@` in pod/member ids, so exactly one `@` separates member and rig).
|
|
"""
|
|
return f"mcp-http-{session}.key"
|
|
|
|
|
|
# HR-4.2: the pinned opencode-go endpoint (fleet.yaml model.base_url must match).
|
|
OPENCODE_GO_BASE_URL = "https://opencode.ai/inference/go/v1"
|
|
|
|
|
|
def die(msg: str) -> None:
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
# provisioning-gate-74 (#74, REQ-P1/P4): read-only existence gate for bound
|
|
# tool artifacts. Resolution is shutil.which + path-exists ONLY — no
|
|
# installs, no pulls, no network. Factors shared by scripts/render-seat.py
|
|
# (render time) and scripts/seed-rig.py (stage time) so both layers degrade
|
|
# with the same warn shape. Never silent: unresolvable artifacts always warn.
|
|
def resolve_tool_artifact(command: str) -> tuple:
|
|
"""Resolve one MCP `command` value read-only.
|
|
|
|
Absolute paths must be executable on disk (shutil.which requires
|
|
executability for slash-paths too); bare names must resolve via
|
|
shutil.which (same PATH the seat will see). The trimmed command is
|
|
treated as ONE complete executable path (no whitespace split; args
|
|
live in args:, never in command:). Returns (ok, artifact)
|
|
where artifact is the command path that was checked.
|
|
"""
|
|
if not isinstance(command, str) or not command.strip():
|
|
return (False, str(command))
|
|
first = command.strip()
|
|
if Path(first).is_absolute():
|
|
return (shutil.which(first) is not None, first)
|
|
if "/" in first:
|
|
return (shutil.which(first) is not None, first)
|
|
return (shutil.which(first) is not None, first)
|
|
|
|
|
|
def bash_leading_binary(pattern: str):
|
|
"""Leading binary of a bash_allow pattern, or None when the pattern is
|
|
a glob/deny form (leading token starts with a glob/metachar) or empty.
|
|
A trailing glob suffix (e.g. `rabin2*`) is cut: the binary is the
|
|
literal prefix. Callers resolve the returned binary with shutil.which."""
|
|
if not isinstance(pattern, str) or not pattern.strip():
|
|
return None
|
|
first = pattern.strip().split()[0]
|
|
if not first:
|
|
return None
|
|
cut = len(first)
|
|
for i, ch in enumerate(first):
|
|
if ch in "*?[]{}()$`;&|<>'\"!#~\\":
|
|
cut = i
|
|
break
|
|
candidate = first[:cut]
|
|
if not candidate:
|
|
return None
|
|
if "/" in candidate:
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def build_seat_files(
|
|
role_id: str,
|
|
rig_id: str,
|
|
pod_id: str,
|
|
member_id: str,
|
|
fleet_data: dict,
|
|
mcp_catalog: dict,
|
|
tools_dir: str,
|
|
secrets_dir: str,
|
|
seat_dir: str,
|
|
charter_text: str,
|
|
model_provider: str = "opencode-go",
|
|
model_id: str = "muse-spark-1.3-contributor",
|
|
allow_missing_secrets: bool = False,
|
|
check_secret_fs: bool = True,
|
|
sandbox_wrapper: str | None = None,
|
|
) -> dict:
|
|
if role_id not in fleet_data.get("roles", {}):
|
|
die(f"unknown role {role_id}")
|
|
role = fleet_data["roles"][role_id]
|
|
|
|
# HR-4.2 (fail-closed): --pod must be a real pod of --rig (fleet/fleet.yaml
|
|
# is canonical). A phantom pod (e.g. --pod command --rig
|
|
# executive-and-architecture, whose pods are exec/delivery) must die here
|
|
# instead of rendering a seat no bootstrap target will ever materialize.
|
|
_valid_pods = set()
|
|
for _r in fleet_data.get("rigs", []):
|
|
if _r.get("id") == rig_id:
|
|
for _p in _r.get("pods", []):
|
|
if _p.get("id"):
|
|
_valid_pods.add(_p["id"])
|
|
_omni = fleet_data.get("omni", {})
|
|
if rig_id == _omni.get("id", "heretek-omni-fleet"):
|
|
_cp = _omni.get("core_pod", {})
|
|
if _cp.get("id"):
|
|
_valid_pods.add(_cp["id"])
|
|
for _f in _omni.get("pod_fragments", []):
|
|
if _f.get("pod"):
|
|
_valid_pods.add(_f["pod"])
|
|
if _valid_pods and pod_id not in _valid_pods:
|
|
die(
|
|
f"unknown pod {pod_id!r} for rig {rig_id} (valid pods: {sorted(_valid_pods)})"
|
|
)
|
|
|
|
for ident in (pod_id, member_id):
|
|
if re.search(r"[.@]", ident):
|
|
die(f"pod/member ids must not contain '.' or '@': {ident}")
|
|
|
|
session = f"{pod_id}-{member_id}@{rig_id}"
|
|
pps_pkg = str(
|
|
Path(tools_dir) / "pi-packages/node_modules/@gotgenes/pi-permission-system"
|
|
)
|
|
# #67 (fail-closed): the permission extension is mandatory — a missing or
|
|
# version-drifted pps must die at render time, never render a seat whose
|
|
# settings.json packages[0] points at nothing. There is NO
|
|
# --allow-missing-secrets escape for this (unlike secrets).
|
|
if check_secret_fs and not (Path(pps_pkg) / "package.json").is_file():
|
|
die(f"{pps_pkg} missing: @gotgenes/pi-permission-system is mandatory")
|
|
if check_secret_fs:
|
|
_pps_ver = json.loads((Path(pps_pkg) / "package.json").read_text()).get(
|
|
"version"
|
|
)
|
|
if _pps_ver != "41.0.0":
|
|
die(f"{pps_pkg} version {_pps_ver}, need 41.0.0")
|
|
|
|
def secret_ok(filename: str) -> bool:
|
|
if not check_secret_fs:
|
|
return False
|
|
p = Path(secrets_dir) / filename
|
|
# HR-4.3 (fail-closed): reject symlinks before stat (TOCTOU / link-swap).
|
|
# A secrets dir containing a symlink must never be trusted — die loudly.
|
|
if p.is_symlink():
|
|
die(f"{p} must not be a symlink")
|
|
if not p.exists():
|
|
return False
|
|
try:
|
|
st = p.stat()
|
|
except FileNotFoundError:
|
|
return False
|
|
# HR-4.3 (fail-closed): ownership pin — secret files must be owned by
|
|
# the invoking user. Group/world-readable is already rejected below;
|
|
# a file owned by another uid (e.g. planted via shared /tmp) must die.
|
|
try:
|
|
euid = os.geteuid()
|
|
except AttributeError:
|
|
euid = None # Windows: no geteuid, skip ownership pin
|
|
if euid is not None and st.st_uid != euid:
|
|
die(f"{p} must be owned by uid {euid} (got {st.st_uid})")
|
|
mode = st.st_mode & 0o777
|
|
if mode & 0o077:
|
|
die(f"{p} is mode {oct(mode)[2:]}; run: chmod 600 {p}")
|
|
# HR-4.3 (fail-closed): O_NOFOLLOW open proves the path is still a
|
|
# regular file at use time (stat-then-use race / symlink swap).
|
|
try:
|
|
fd = os.open(p, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
except FileNotFoundError:
|
|
return False
|
|
except OSError:
|
|
die(f"{p} cannot be opened safely (symlink/race)")
|
|
else:
|
|
os.close(fd)
|
|
return True
|
|
|
|
def subst(val):
|
|
if isinstance(val, str):
|
|
return (
|
|
val.replace("@TOOLS@", tools_dir)
|
|
.replace("@SECRETS@", secrets_dir)
|
|
.replace("@SEAT@", seat_dir)
|
|
)
|
|
return val
|
|
|
|
files = {}
|
|
|
|
# 1. auth.json & models.json
|
|
if model_provider == "opencode-go":
|
|
has_key = secret_ok("opencode-go.key")
|
|
if not has_key and not allow_missing_secrets:
|
|
die(f"missing {Path(secrets_dir) / 'opencode-go.key'} (chmod 600)")
|
|
# HR-4.3 (fail-closed): never emit a dangling `!cat` for the primary
|
|
# provider key. Under --allow-missing-secrets an absent key renders
|
|
# no provider entry at all plus a stderr warning, so the seat is
|
|
# visibly degraded instead of pointing at a nonexistent secret. No
|
|
# literal is ever materialized. #102 HR-4.2-auth-enabled-ignored: pi
|
|
# Pi's auth-storage (1.3.0 and 1.4.0) never reads `enabled`, so an entry carrying
|
|
# `"enabled": false` still ran the `!cat` at call time.
|
|
if has_key:
|
|
files["auth.json"] = {
|
|
"opencode-go": {
|
|
"type": "api_key",
|
|
"key": f"!cat {Path(secrets_dir) / 'opencode-go.key'}",
|
|
}
|
|
}
|
|
else:
|
|
if check_secret_fs:
|
|
print(
|
|
f"warn: {session}: opencode-go.key missing, auth disabled",
|
|
file=sys.stderr,
|
|
)
|
|
files["auth.json"] = {}
|
|
# #102: fleet.yaml `model:` is the source for the endpoint and window
|
|
# sizes (it used to be decorative while these were hardcoded here).
|
|
# HR-4.2 keeps the endpoint pinned: a fleet base_url that differs from
|
|
# the pin dies, so moving endpoints is a deliberate two-place change.
|
|
model_cfg = fleet_data.get("model") or {}
|
|
for key in ("api", "base_url", "context_window", "max_tokens"):
|
|
if key not in model_cfg:
|
|
die(f"fleet model.{key} is required for provider {model_provider}")
|
|
if model_cfg["api"] != "openai-responses":
|
|
die(
|
|
f"fleet model.api must be openai-responses for {model_provider} (got {model_cfg['api']!r})"
|
|
)
|
|
for key in ("context_window", "max_tokens"):
|
|
val = model_cfg[key]
|
|
if isinstance(val, bool) or not isinstance(val, int) or val <= 0:
|
|
die(f"fleet model.{key} must be a positive integer (got {val!r})")
|
|
if model_cfg["max_tokens"] > model_cfg["context_window"]:
|
|
die("fleet model.max_tokens must not exceed model.context_window")
|
|
if model_cfg["base_url"] != OPENCODE_GO_BASE_URL:
|
|
die(
|
|
f"fleet model.base_url must be the pinned {OPENCODE_GO_BASE_URL} "
|
|
f"(got {model_cfg['base_url']!r})"
|
|
)
|
|
files["models.json"] = {
|
|
"providers": {
|
|
"opencode-go": {
|
|
"api": model_cfg["api"],
|
|
"baseUrl": model_cfg["base_url"],
|
|
"models": [
|
|
{
|
|
"id": "muse-spark-1.3-contributor",
|
|
"name": "Muse Spark 1.3 Contributor",
|
|
"reasoning": True,
|
|
"input": ["text", "image"],
|
|
"thinkingLevelMap": {
|
|
"off": None,
|
|
"minimal": "minimal",
|
|
"low": "low",
|
|
"medium": "medium",
|
|
"high": "high",
|
|
"xhigh": "xhigh",
|
|
"max": None,
|
|
},
|
|
"contextWindow": model_cfg["context_window"],
|
|
"maxTokens": model_cfg["max_tokens"],
|
|
"cost": {
|
|
"input": 0.10,
|
|
"output": 0.20,
|
|
"cacheRead": 0.002,
|
|
"cacheWrite": 0,
|
|
},
|
|
"compat": {
|
|
"supportsDeveloperRole": True,
|
|
"sendSessionIdHeader": True,
|
|
"supportsLongCacheRetention": True,
|
|
"sessionAffinityFormat": "openai-nosession",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
# 2. mcp.json
|
|
mcp_servers = {}
|
|
mcp_allow = {"*": "deny"}
|
|
deferred = False
|
|
for b in role.get("mcp", []):
|
|
srv_name = b["server"]
|
|
if srv_name not in mcp_catalog:
|
|
die(f"role {role_id} binds unknown MCP server {srv_name}")
|
|
s = mcp_catalog[srv_name]
|
|
need = list(s.get("required_secrets", []))
|
|
if s.get(SEAT_GRANT_FLAG):
|
|
# REQ-I1: the per-seat grant token file is dynamic (one per
|
|
# session), so it cannot sit in the static required_secrets
|
|
# list — it joins the same missing/degraded machinery instead.
|
|
need.append(seat_grant_token_filename(session))
|
|
missing = [f for f in need if not secret_ok(f)]
|
|
toolset = b.get("toolset", "")
|
|
entry = {
|
|
"description": subst(s["description"]).replace("{toolset}", toolset),
|
|
"exposure": s["exposure"],
|
|
}
|
|
if s["transport"] == "http":
|
|
entry["url"] = s["url_template"].replace("{toolset}", toolset or "repos")
|
|
entry["headers"] = {k: subst(v) for k, v in s.get("headers", {}).items()}
|
|
if s.get(SEAT_GRANT_FLAG) and not missing:
|
|
# REQ-I1: wire the seat's own grant as the existing Bearer
|
|
# header form (same shape as the github Authorization entry).
|
|
token_path = Path(secrets_dir) / seat_grant_token_filename(session)
|
|
entry["headers"] = {
|
|
"Authorization": f"!echo Bearer $(cat {token_path})"
|
|
}
|
|
else:
|
|
entry["command"] = subst(s["command"])
|
|
entry["args"] = [subst(x) for x in s.get("args", [])]
|
|
if any(str(x).startswith("!") for x in entry["args"]):
|
|
die(f"{srv_name}: Pi never expands !command in args")
|
|
if "env" in s:
|
|
entry["env"] = {k: subst(v) for k, v in s["env"].items()}
|
|
|
|
for opt_sec in s.get("optional_secrets", []):
|
|
if not secret_ok(opt_sec) and "env" in entry:
|
|
for k in list(entry["env"].keys()):
|
|
if opt_sec in str(entry["env"][k]):
|
|
del entry["env"][k]
|
|
|
|
if missing:
|
|
if not allow_missing_secrets:
|
|
missing_paths = ", ".join(str(Path(secrets_dir) / f) for f in missing)
|
|
die(f"{srv_name} needs {missing_paths}")
|
|
entry["enabled"] = False
|
|
if check_secret_fs:
|
|
print(
|
|
f"warn: {session}: {srv_name} disabled, missing secrets: {', '.join(missing)}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# provisioning-gate-74 (#74, REQ-P1/P4): read-only render-time
|
|
# resolution of the bound stdio command (remote-http servers carry
|
|
# no command and are excluded). Unresolvable entries render
|
|
# enabled:false AND warn naming server, seat, and artifact — never
|
|
# silent (mirrors the degraded-secrets path above).
|
|
if s["transport"] != "http" and "command" in s:
|
|
_ok74, _art74 = resolve_tool_artifact(entry.get("command", ""))
|
|
if not _ok74:
|
|
entry["enabled"] = False
|
|
# CodeQL py/clear-text-logging-sensitive-data: log the raw
|
|
# catalog template token (e.g. @TOOLS@/bin/r2mcp), never the
|
|
# substituted absolute path derived from secrets/tools dirs.
|
|
_raw74 = str(s.get("command", "")).strip().split()
|
|
_raw74 = _raw74[0] if _raw74 else ""
|
|
print(
|
|
f"warn: {session}: {srv_name} disabled, missing tool artifact: {_raw74}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
mcp_servers[srv_name] = entry
|
|
mcp_allow[srv_name] = "allow"
|
|
if s["exposure"] == "deferred":
|
|
deferred = True
|
|
|
|
files["mcp.json"] = {"mcpServers": mcp_servers}
|
|
|
|
# 09-send-identity S1 (REQ-S1): per-seat sender-binding reference for
|
|
# grant-bound seats (same gate as the REQ-I1 grant header: the role binds
|
|
# a catalog server carrying `seat_grant: true`). The binding secret is
|
|
# seed-issued (0600 store) and is referenced here as a `!cat` whole-value
|
|
# form — never an inline secret. Strict render dies on a missing binding
|
|
# file (the relay cannot attest without it); degraded render marks the
|
|
# entry disabled so the seat holds sends UNVERIFIED instead of pointing
|
|
# at a nonexistent secret. Unbound roles render no entry at all.
|
|
import importlib.util as _ilu09
|
|
|
|
_bound09 = [
|
|
b
|
|
for b in role.get("mcp", []) or []
|
|
if isinstance(b, dict)
|
|
and mcp_catalog.get(b.get("server"), {}).get(SEAT_GRANT_FLAG)
|
|
]
|
|
if _bound09:
|
|
# Lazy: unbound roles never touch the attestation module (zero
|
|
# render cost, no hard dependency); bound roles fail closed here
|
|
# when the module is absent instead of rendering without identity.
|
|
_si09_spec = _ilu09.spec_from_file_location(
|
|
"send_identity_render",
|
|
Path(__file__).resolve().parent / "send_identity.py",
|
|
)
|
|
try:
|
|
_si09_mod = _ilu09.module_from_spec(_si09_spec)
|
|
_si09_spec.loader.exec_module(_si09_mod)
|
|
except Exception as _e09:
|
|
die(f"send-identity module missing (scripts/send_identity.py: {_e09})")
|
|
_sbfn09 = _si09_mod.sender_binding_filename(session)
|
|
if secret_ok(_sbfn09):
|
|
files["send-identity.json"] = {
|
|
"sender": session,
|
|
"scope": {"rig": rig_id},
|
|
"binding": f"!cat {Path(secrets_dir) / _sbfn09}",
|
|
"version": _si09_mod.ATTESTATION_VERSION,
|
|
}
|
|
elif not allow_missing_secrets:
|
|
die(f"send-identity needs {Path(secrets_dir) / _sbfn09} (chmod 600)")
|
|
else:
|
|
if check_secret_fs:
|
|
print(
|
|
f"warn: {session}: send-identity disabled, missing binding: {_sbfn09}",
|
|
file=sys.stderr,
|
|
)
|
|
files["send-identity.json"] = {"sender": session, "enabled": False}
|
|
|
|
# 3. settings.json
|
|
tools = ["read", "grep", "find", "ls", "bash", "edit", "write"]
|
|
if deferred:
|
|
tools.append("tool_search")
|
|
|
|
packages = [pps_pkg]
|
|
role_pkgs = role.get("pi_packages", ["@gotgenes/[email protected]"])
|
|
has_ast_lsp = any(
|
|
p.startswith(
|
|
(
|
|
"pi-lsp-extension",
|
|
"pi-ast-grep",
|
|
"@narumitw/pi-lsp",
|
|
"@nicknisi/pi-ast-grep",
|
|
)
|
|
)
|
|
for p in role_pkgs
|
|
)
|
|
for pkg in role_pkgs:
|
|
if pkg.startswith("@gotgenes/pi-permission-system"):
|
|
continue
|
|
pkg_name = (
|
|
pkg.split("@")[0]
|
|
if not pkg.startswith("@")
|
|
else "@" + pkg[1:].split("@")[0]
|
|
)
|
|
candidate = Path(tools_dir) / "pi-packages/node_modules" / pkg_name
|
|
if candidate.exists():
|
|
packages.append(str(candidate))
|
|
|
|
files["settings.json"] = {
|
|
"defaultProvider": model_provider,
|
|
"defaultModel": model_id,
|
|
"defaultThinkingLevel": role["thinking"],
|
|
"defaultTools": tools,
|
|
"packages": packages,
|
|
"quietStartup": True,
|
|
# M4 (#68): route pi's bash tool through the bubblewrap jail. A
|
|
# missing shellPath fails closed (no /bin/bash fallback), and the
|
|
# wrapper must sit outside every seat's write scope (Gotcha 5).
|
|
# Generated reference leaves pass the `@RIGS@` placeholder so they
|
|
# never depend on where the repo is checked out (#105 review).
|
|
"shellPath": sandbox_wrapper
|
|
or str(Path(__file__).resolve().parent / "seat-sandbox.sh"),
|
|
}
|
|
|
|
# M4 (#68): per-seat sandbox env for the wrapper. The pi-runner child env
|
|
# is deny-by-default (no HERETEK_* crosses into the pi agent process),
|
|
# but PI_CODING_AGENT_DIR always points at this agent dir, so the wrapper
|
|
# loads sandbox.env from there (see scripts/seat-sandbox.sh bootstrap).
|
|
_bind_roots = _sandbox_bind_roots(role["permissions"]["path_write"])
|
|
files["sandbox.env"] = (
|
|
f"HERETEK_SANDBOX_WRITE={shlex.quote(':'.join(_bind_roots))}\n"
|
|
f"HERETEK_SECRET_STORE={shlex.quote(secrets_dir)}\n"
|
|
)
|
|
|
|
# 4. extensions/pi-permission-system/config.json
|
|
# Last matching rule wins inside each map; guards MUST go LAST.
|
|
path_write = {"*": "deny"}
|
|
for g in role["permissions"]["path_write"]:
|
|
path_write[g] = "allow"
|
|
for guard_k in (
|
|
".git/*",
|
|
"*/.git/*",
|
|
"AGENTS.md",
|
|
"*/AGENTS.md",
|
|
".pi/*",
|
|
"*/.pi/*",
|
|
".agents/*",
|
|
"*/.agents/*",
|
|
"fleet/*",
|
|
"scripts/*",
|
|
"*..*",
|
|
):
|
|
path_write.pop(guard_k, None)
|
|
path_write[guard_k] = "deny"
|
|
|
|
bash_map = {"*": "deny"}
|
|
for p in role["permissions"]["bash_allow"]:
|
|
# HR-5.5 (fail-closed): never emit a shell-metachar allowlist entry —
|
|
# `; & | $` backticks, parens, newlines, redirections (`>`, `<`),
|
|
# backslash, `!`, `#`, `~`, braces, brackets, `?`, or CR could chain
|
|
# out of a trailing-* prefix under prefix-glob matcher semantics.
|
|
if re.search(r"[;&|$`()\n><\\!#~{}\[\]?\r]", p) or "$(" in p:
|
|
die(f"{role_id}: bash_allow pattern contains shell metachar: {p!r}")
|
|
# HR-5.5-render-output-allow (fail-closed): `--output=` smuggles a file
|
|
# write past path_write under prefix-glob matcher semantics — reject at
|
|
# generation time, not only in the verifier (direct render-seat.py use
|
|
# must never materialize the escape).
|
|
if "--output" in p:
|
|
die(
|
|
f"{role_id}: bash_allow pattern contains forbidden --output write-escape: {p!r}"
|
|
)
|
|
bash_map[p] = "allow"
|
|
# HR-F18 (#102): per-seat bare queue/send denies from fleet
|
|
# `permissions.bash_deny`. Rendered AFTER role allows so last-match-wins
|
|
# denies the exact bare strings (`rig queue list`, `rig queue create`,
|
|
# `rig send`) while the trailing-` *` allows above keep the `--json` /
|
|
# `--json --verify` / addressed-send forms live (pps compiles a
|
|
# trailing `" *"` to an optional-args group, so the bare command matched
|
|
# the allow before this deny existed). Entries render BEFORE the
|
|
# TERMINAL_BASH_DENIES tuple below (which stays frozen per REQ-G1):
|
|
# denies never trip the no-allow-after-terminal gate. Fail-closed on
|
|
# shape: non-empty strings under the same metachar/`--output` bans as
|
|
# allows (`*`/`?` glob chars stay permitted).
|
|
for _deny_entry in role["permissions"].get("bash_deny", []) or []:
|
|
if not isinstance(_deny_entry, str) or not _deny_entry:
|
|
die(
|
|
f"{role_id}: bash_deny entries must be non-empty strings, got {_deny_entry!r}"
|
|
)
|
|
if (
|
|
re.search(r"[;&|$`()\n><\\!#~{}\[\]?\r]", _deny_entry)
|
|
or "$(" in _deny_entry
|
|
):
|
|
die(
|
|
f"{role_id}: bash_deny pattern contains shell metachar: {_deny_entry!r}"
|
|
)
|
|
if "--output" in _deny_entry:
|
|
die(
|
|
f"{role_id}: bash_deny pattern contains forbidden --output write-escape: {_deny_entry!r}"
|
|
)
|
|
bash_map[_deny_entry] = "deny"
|
|
# HR-5.5 (fail-closed): prefix-glob matcher semantics — a live command
|
|
# appending `--output=/tmp/x` (or `;`, `|`, `>`, `<`, `&`, backtick,
|
|
# `$()`, subshell parens) to an allowed trailing-* prefix would still
|
|
# match the allow prefix under prefix-glob matcher semantics. Terminal
|
|
# deny rules placed LAST (last-match-wins, same as path_write/path guards)
|
|
# ensure any command containing those substrings is denied even when it
|
|
# starts with an allowed prefix. Legitimate allows contain none of these
|
|
# substrings (fleet verified metachar-free), so the clean fleet renders
|
|
# unchanged except for these added terminal denies. Newline (`\n`) cannot
|
|
# be expressed as a portable glob and stays covered by the generation-time
|
|
# metachar die above plus Pi's own compound-command decomposition
|
|
# (docs/OSINT_ECOSYSTEM_REPORT.md gate probes); live `&`/backtick/`$()`
|
|
# semantics remain UNVERIFIED pending a hostile-corpus run through the
|
|
# installed @gotgenes/[email protected] (HR-5.5-enforcement-gap).
|
|
# HR-5.5-git-patch-read-bypass (fail-closed): patch-output flags smuggle
|
|
# full file contents past path read-denies under prefix-glob matcher
|
|
# semantics (e.g. `git log --oneline -p -- fleet/fleet.yaml` matches the
|
|
# `git log --oneline*` allow while `fleet/*` stays `"deny"` in `path`).
|
|
# Deny the patch-output flag family LAST for the same reason. Residual
|
|
# (documented, P3): `--stat`/`--name-only` output is metadata-only (no
|
|
# file contents); path-qualified git commands such as
|
|
# `git show --stat HEAD -- fleet/fleet.yaml` still match their allow
|
|
# prefix. Blast radius stays capped at tracked content — secrets live
|
|
# untracked in $HERETEK_SECRETS (4.3/4.4) and the `*.env`/`*id_rsa*`
|
|
# direct-read denies still hold.
|
|
# HR-5.5-tab-p-suffix-gap (fail-closed): `* -p*` is space-anchored, so a
|
|
# tab-separated `git log --oneline<TAB>-p` matches the allow but no deny
|
|
# under last-match-wins substring-glob semantics. The `"*\t-p*"` tab
|
|
# variant closes the gap (JSON `\t` decodes to TAB at match time).
|
|
# S2a/M9: the deny tuple lives in module-level TERMINAL_BASH_DENIES
|
|
# (single source of truth, asserted identical to the verify-rigs
|
|
# mirror in V13).
|
|
for _deny_pat in TERMINAL_BASH_DENIES:
|
|
bash_map[_deny_pat] = "deny"
|
|
# #91 argv-slot exception (REQ-A2): fleet-catalog overlay patterns for
|
|
# the gcc compile slot render AFTER the terminal denies, so the grant
|
|
# takes last-match-wins precedence FOR THAT ARGV SLOT ONLY — mirroring
|
|
# #106. The tuple above stays byte-identical (REQ-G1); the denies still
|
|
# fire outside the slot (red-proofed: `sh -c`, chains, -o escapes deny).
|
|
# Only patterns both catalog-listed AND in this role's bash_allow move;
|
|
# anything else stays in pre-deny position (V1 subset check still holds).
|
|
_overlay91 = set(
|
|
((fleet_data.get("command_effects") or {}).get("gcc_effect") or {}).get(
|
|
"bash_overlay_patterns"
|
|
)
|
|
or []
|
|
)
|
|
for _op91 in role["permissions"]["bash_allow"]:
|
|
if str(_op91) in _overlay91:
|
|
bash_map.pop(str(_op91), None)
|
|
bash_map[str(_op91)] = "allow"
|
|
# provisioning-gate-74 (#74, REQ-P1/P4): read-only render-time
|
|
# resolution of each bash_allow leading binary (r2/rabin2/tshark and
|
|
# friends). Missing binaries warn naming seat, grant, and artifact —
|
|
# never silent. The allow entry is kept (policy unchanged): the warn is
|
|
# the loud degradation, and provisioning the binary silences it.
|
|
for p in role["permissions"]["bash_allow"]:
|
|
_bin74 = bash_leading_binary(p)
|
|
if _bin74 is not None and shutil.which(_bin74) is None:
|
|
print(
|
|
f"warn: {session}: bash grant {p!r} missing tool artifact: {_bin74}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
permission = {
|
|
"*": "deny",
|
|
"read": "allow",
|
|
"grep": "allow",
|
|
"find": "allow",
|
|
"ls": "allow",
|
|
"write": "allow",
|
|
"edit": "allow",
|
|
}
|
|
if has_ast_lsp:
|
|
for t_ro in READONLY_AST_LSP_TOOLS:
|
|
permission[t_ro] = "allow"
|
|
for t_mut in MUTATING_AST_LSP_TOOLS:
|
|
permission[t_mut] = "deny"
|
|
if deferred:
|
|
permission["tool_search"] = "allow"
|
|
|
|
permission["bash"] = bash_map
|
|
permission["path"] = {
|
|
"*": "allow",
|
|
"*.env": "deny",
|
|
"*.env.*": "deny",
|
|
"*.env.example": "allow",
|
|
"*.pem": "deny",
|
|
"*.key": "deny",
|
|
"*id_rsa*": "deny",
|
|
"*kubeconfig*": "deny",
|
|
"*/secrets/*": "deny",
|
|
# HR-5.2 (fail-closed): the same dot-directory/file guard block as
|
|
# path_write — reads of .git/.pi/.agents metadata, seat charters, and
|
|
# the fleet toolchain itself must deny (live corpus: gate-role.sh
|
|
# .pi/.agents read probes). Guards sit before terminal "*..*".
|
|
".git/*": "deny",
|
|
"*/.git/*": "deny",
|
|
"AGENTS.md": "deny",
|
|
"*/AGENTS.md": "deny",
|
|
".pi/*": "deny",
|
|
"*/.pi/*": "deny",
|
|
".agents/*": "deny",
|
|
"*/.agents/*": "deny",
|
|
"fleet/*": "deny",
|
|
"scripts/*": "deny",
|
|
# HR-5.3 (fail-closed): explicit denies for every secret-filename suffix
|
|
# in fleet/mcp-servers.yaml so coverage never depends on the secrets
|
|
# dir containing a `/secrets/` segment (default $HERETEK_SECRETS has none).
|
|
"*.pat": "deny",
|
|
"*.uri": "deny",
|
|
"*.profile": "deny",
|
|
"*.region": "deny",
|
|
"*.host": "deny",
|
|
"*.password": "deny",
|
|
"*.credentials": "deny",
|
|
"*.config": "deny",
|
|
# HR-5.3 (fail-closed): credential-bearing URL/user filename suffixes
|
|
# (e.g. prometheus.url, clickhouse.user) must never be readable even
|
|
# outside any */secrets/* path.
|
|
"*.url": "deny",
|
|
"*.user": "deny",
|
|
"*..*": "deny",
|
|
}
|
|
permission["path_write"] = path_write
|
|
# #91 Go `...` relief (REQ-A1): `...`-suffixed package patterns trip the
|
|
# `*..*` guard above although they are not paths. For roles holding go
|
|
# verbs, an explicit path_read `*/...` allow takes final say over the
|
|
# sugar-expanded `*..*` deny (pps: explicit directional entries win).
|
|
# Traversal still denies at the external_directory boundary (out of
|
|
# cwd) — live-probed. No other role gains this exception.
|
|
_binaries91 = set()
|
|
for _pat91 in role["permissions"]["bash_allow"]:
|
|
_bin91 = bash_leading_binary(str(_pat91))
|
|
if _bin91 is not None:
|
|
_binaries91.add(_bin91)
|
|
if "go" in _binaries91:
|
|
permission["path_read"] = {"*/...": "allow"}
|
|
permission["mcp"] = mcp_allow
|
|
permission["external_directory"] = "deny"
|
|
|
|
files["extensions/pi-permission-system/config.json"] = {
|
|
"promptTimeoutMs": 60000,
|
|
"permission": permission,
|
|
# #91 (REQ-A0/A1): M0 live probe through installed pps 41.0.0 proved
|
|
# the descriptor-LIST form is unparsed (whole layer fails closed to
|
|
# ask) while the native MAP form enforces. config.json carries the
|
|
# native map; the audit descriptors ride in the sidecar below.
|
|
"commandEffects": build_command_effects_map(role["permissions"]["bash_allow"]),
|
|
}
|
|
files["extensions/pi-permission-system/command-effects.descriptors.json"] = (
|
|
build_command_effects(
|
|
role["permissions"]["path_write"],
|
|
role["permissions"]["bash_allow"],
|
|
)
|
|
)
|
|
|
|
# 5. AGENTS.md: role charter + seat block (never use guidance_merge on shared cwd)
|
|
esc = role.get("reports_to")
|
|
esc_line = (
|
|
f'- Escalate with: `rig send {esc["address"]} "<message>"` ({esc["role"]}{", same rig" if esc.get("same_rig") else ", other rig"}).'
|
|
if esc
|
|
else "- You are the top of the escalation chain; escalate to the human."
|
|
)
|
|
writable_str = (
|
|
", ".join(f"`{g}`" for g in role["permissions"]["path_write"]) or "none"
|
|
)
|
|
addr_block = "\n".join(
|
|
[
|
|
"## Seat",
|
|
"",
|
|
f"- You are `{role_id}` in seat `{session}` (rig `{rig_id}`, pod `{pod_id}`).",
|
|
f"- Model: {model_provider}/{model_id}, thinking {role['thinking']}. Recorded policy: {role['permission_policy']}.",
|
|
esc_line,
|
|
f"- Writable paths: {writable_str}. Everything else is read-only.",
|
|
"",
|
|
]
|
|
)
|
|
files["AGENTS.md"] = charter_text.rstrip() + "\n\n" + addr_block
|
|
return files
|
|
|
|
|
|
def refresh_seat_policy(
|
|
out_dir: Path, session: str, files: dict, policy_dir: str
|
|
) -> None:
|
|
"""Refresh a live seat's permission policy and sandbox.env in place (#96, #119)."""
|
|
live_cfg = out_dir / policy_dir / "config.json"
|
|
resolved_out = out_dir.resolve()
|
|
for rel in sorted(
|
|
(r for r in files if r == "sandbox.env" or r.startswith(policy_dir)),
|
|
key=lambda r: r.endswith("/config.json"),
|
|
):
|
|
target = (out_dir / rel).resolve()
|
|
if not target.is_relative_to(resolved_out):
|
|
die(f"invalid relative path in policy refresh: {rel}")
|
|
body = files[rel]
|
|
content = (
|
|
body
|
|
if isinstance(body, str)
|
|
else json.dumps(body, indent=2, ensure_ascii=False) + "\n"
|
|
)
|
|
tmp = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
|
tmp.write_text(content)
|
|
os.chmod(tmp, 0o600)
|
|
# Same-directory rename: the watcher never reads a half-written file.
|
|
os.replace(tmp, target)
|
|
print(f"refreshed policy {session} -> {live_cfg}")
|
|
|
|
|
|
def main() -> None:
|
|
if "--help" in sys.argv or "-h" in sys.argv:
|
|
print(USAGE)
|
|
sys.exit(0)
|
|
|
|
parser = argparse.ArgumentParser(add_help=False)
|
|
parser.add_argument("--role")
|
|
parser.add_argument("--rig")
|
|
parser.add_argument("--pod")
|
|
parser.add_argument("--member")
|
|
parser.add_argument("--fleet", default="fleet/fleet.yaml")
|
|
parser.add_argument("--mcp", default="fleet/mcp-servers.yaml")
|
|
parser.add_argument("--charter")
|
|
parser.add_argument("--out")
|
|
parser.add_argument("--model-provider", default="opencode-go")
|
|
parser.add_argument("--model-id", default="muse-spark-1.3-contributor")
|
|
parser.add_argument("--allow-missing-secrets", action="store_true")
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--policy-only", action="store_true")
|
|
args, unknown = parser.parse_known_args()
|
|
if unknown:
|
|
die(f"unknown arguments: {' '.join(unknown)}\n{USAGE}")
|
|
for req in ("role", "rig", "pod"):
|
|
if not getattr(args, req):
|
|
die(f"--{req} is required\n{USAGE}")
|
|
|
|
home = Path.home()
|
|
openrig_home = Path(
|
|
os.environ.get("OPENRIG_HOME", str(home / ".openrig"))
|
|
).resolve()
|
|
tools_dir = str(
|
|
Path(
|
|
os.environ.get(
|
|
"HERETEK_TOOLS", str(home / ".local/share/heretek-rigs/tools")
|
|
)
|
|
).resolve()
|
|
)
|
|
secrets_dir = str(
|
|
Path(
|
|
os.environ.get(
|
|
"HERETEK_SECRETS", str(home / ".config/heretek-rigs/secrets")
|
|
)
|
|
).resolve()
|
|
)
|
|
|
|
fleet_data = yaml.safe_load(Path(args.fleet).read_text())
|
|
mcp_catalog = yaml.safe_load(Path(args.mcp).read_text())["servers"]
|
|
if args.role not in fleet_data.get("roles", {}):
|
|
die(f"unknown role {args.role}")
|
|
role = fleet_data["roles"][args.role]
|
|
member_id = args.member or args.role
|
|
session = f"{args.pod}-{member_id}@{args.rig}"
|
|
seat_dir = str(openrig_home / "state/pi" / session)
|
|
out_dir = Path(args.out).resolve() if args.out else Path(seat_dir) / "agent"
|
|
charter_path = (
|
|
Path(args.charter)
|
|
if args.charter
|
|
else Path("specs/agents") / role["domain"] / args.role / "guidance/role.md"
|
|
)
|
|
if not charter_path.exists():
|
|
die(f"missing charter {charter_path}")
|
|
|
|
files = build_seat_files(
|
|
role_id=args.role,
|
|
rig_id=args.rig,
|
|
pod_id=args.pod,
|
|
member_id=member_id,
|
|
fleet_data=fleet_data,
|
|
mcp_catalog=mcp_catalog,
|
|
tools_dir=tools_dir,
|
|
secrets_dir=secrets_dir,
|
|
seat_dir=seat_dir,
|
|
charter_text=charter_path.read_text(),
|
|
model_provider=args.model_provider,
|
|
model_id=args.model_id,
|
|
allow_missing_secrets=args.allow_missing_secrets,
|
|
check_secret_fs=True,
|
|
)
|
|
|
|
policy_dir = "extensions/pi-permission-system/"
|
|
if args.policy_only:
|
|
# #96: refresh a LIVE seat's permission map only. Never create a seat
|
|
# here: the path must be the one the running pi already reads.
|
|
live_cfg = out_dir / policy_dir / "config.json"
|
|
if not live_cfg.is_file():
|
|
die(f"--policy-only needs an already-seeded seat: {live_cfg} not found")
|
|
|
|
if args.dry_run:
|
|
print(json.dumps(files, indent=2, ensure_ascii=False))
|
|
return
|
|
|
|
if args.policy_only:
|
|
refresh_seat_policy(out_dir, session, files, policy_dir)
|
|
return
|
|
|
|
resolved_out = out_dir.resolve()
|
|
for rel, body in files.items():
|
|
p = (out_dir / rel).resolve()
|
|
if not p.is_relative_to(resolved_out):
|
|
die(f"invalid relative path: {rel}")
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
content = (
|
|
body
|
|
if isinstance(body, str)
|
|
else json.dumps(body, indent=2, ensure_ascii=False) + "\n"
|
|
)
|
|
p.write_text(content)
|
|
os.chmod(p, 0o600)
|
|
(Path(seat_dir) / "sessions").mkdir(parents=True, exist_ok=True)
|
|
print(f"rendered {session} -> {out_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|