nobrainer-tech-flow 0.0.0-stage → 2.4.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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +47 -0
- package/.opencode/INSTALL.md +37 -0
- package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
- package/.pi/extensions/nobrainer-tech-skills.js +64 -0
- package/AGENTS.md +204 -0
- package/CLAUDE.md +204 -0
- package/CONTRIBUTING.md +70 -0
- package/GEMINI.md +1 -0
- package/LICENSE +21 -0
- package/README.md +658 -2
- package/SECURITY.md +33 -0
- package/adapters/bootstrap.md +27 -0
- package/assets/nobrainer-tech-logo.svg +1 -0
- package/assets/nobrainer-workflow.svg +155 -0
- package/assets/plugin-logo.png +0 -0
- package/bin/nobrainer-tech-flow.mjs +48 -0
- package/docs/BOUNDED_RUNNER.md +60 -0
- package/docs/CHAT_PLUGINS.md +29 -0
- package/docs/COMPATIBILITY.md +371 -0
- package/docs/INSTALL.md +626 -0
- package/docs/MIGRATION_TO_FLOW.md +117 -0
- package/docs/NAMING.md +39 -0
- package/docs/PRIVACY.md +9 -0
- package/docs/SESSION_RESTART.md +70 -0
- package/docs/SKILL_CURATION.md +124 -0
- package/docs/TESTING.md +232 -0
- package/docs/TRY_IT.md +109 -0
- package/docs/releases/v1.0.0.md +81 -0
- package/docs/releases/v1.1.0.md +85 -0
- package/docs/releases/v1.10.0.md +34 -0
- package/docs/releases/v1.11.0-publication-readback.md +25 -0
- package/docs/releases/v1.11.0.md +42 -0
- package/docs/releases/v1.12.0.md +30 -0
- package/docs/releases/v1.13.0.md +46 -0
- package/docs/releases/v1.14.0.md +43 -0
- package/docs/releases/v1.14.1.md +21 -0
- package/docs/releases/v1.2.0.md +87 -0
- package/docs/releases/v1.2.1.md +92 -0
- package/docs/releases/v1.3.0-publication-readback.md +69 -0
- package/docs/releases/v1.3.0.md +66 -0
- package/docs/releases/v1.3.1-publication-readback.md +76 -0
- package/docs/releases/v1.3.1.md +59 -0
- package/docs/releases/v1.4.0-publication-readback.md +81 -0
- package/docs/releases/v1.4.0.md +43 -0
- package/docs/releases/v1.5.0-publication-readback.md +76 -0
- package/docs/releases/v1.5.0.md +54 -0
- package/docs/releases/v1.6.0.md +64 -0
- package/docs/releases/v1.6.1-publication-readback.md +53 -0
- package/docs/releases/v1.6.1.md +82 -0
- package/docs/releases/v1.7.0.md +42 -0
- package/docs/releases/v1.7.1-publication-readback.md +64 -0
- package/docs/releases/v1.7.1.md +49 -0
- package/docs/releases/v1.8.0.md +63 -0
- package/docs/releases/v1.8.1.md +32 -0
- package/docs/releases/v1.9.0.md +33 -0
- package/docs/releases/v2.0.0-candidate.md +28 -0
- package/docs/releases/v2.0.0.md +53 -0
- package/docs/releases/v2.0.1.md +135 -0
- package/docs/releases/v2.0.2.md +189 -0
- package/docs/releases/v2.1.0.md +64 -0
- package/docs/releases/v2.1.1.md +43 -0
- package/docs/releases/v2.2.0.md +64 -0
- package/docs/releases/v2.2.1.md +11 -0
- package/docs/releases/v2.3.0.md +97 -0
- package/docs/releases/v2.3.1.md +71 -0
- package/docs/releases/v2.3.2.md +242 -0
- package/docs/releases/v2.4.0.md +126 -0
- package/gemini-extension.json +6 -0
- package/hooks/claude-hooks.json +17 -0
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/run-hook.cmd +36 -0
- package/hooks/session-start +38 -0
- package/package.json +69 -4
- package/plugin.json +51 -0
- package/scripts/build_plugin.py +50 -0
- package/scripts/check_flow_update.py +18 -0
- package/scripts/check_public_commit.py +86 -0
- package/scripts/fetch_public_refs.py +69 -0
- package/scripts/install.py +600 -0
- package/scripts/install.sh +1392 -0
- package/scripts/install_guards.py +279 -0
- package/scripts/install_personalization.py +527 -0
- package/scripts/install_skills.py +951 -0
- package/scripts/npm_install.py +85 -0
- package/scripts/recommend_flow_setup.py +1522 -0
- package/scripts/validate_skills.py +465 -0
- package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
- package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
- package/skills/nobrainer-autoimprove/SKILL.md +212 -0
- package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
- package/skills/nobrainer-browser/SKILL.md +234 -0
- package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
- package/skills/nobrainer-build/SKILL.md +173 -0
- package/skills/nobrainer-codex-context/SKILL.md +111 -0
- package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
- package/skills/nobrainer-decide/SKILL.md +198 -0
- package/skills/nobrainer-dispatcher/SKILL.md +256 -0
- package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
- package/skills/nobrainer-rca/SKILL.md +163 -0
- package/skills/nobrainer-research/SKILL.md +131 -0
- package/skills/nobrainer-review/SKILL.md +195 -0
- package/skills/nobrainer-security/SKILL.md +166 -0
- package/skills/nobrainer-sessions/SKILL.md +223 -0
- package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
- package/skills/nobrainer-sessions/references/protocol.md +337 -0
- package/skills/nobrainer-sessions/references/session-restart.md +255 -0
- package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
- package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
- package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
- package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
- package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
- package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
- package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
- package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
- package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
- package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
- package/skills/nobrainer-team/SKILL.md +165 -0
- package/skills/nobrainer-team/references/team-plan.md +64 -0
- package/skills/nobrainer-tech-flow/SKILL.md +239 -0
- package/skills/nobrainer-tech-flow/VERSION +1 -0
- package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
- package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
- package/skills/nobrainer-tech-flow/references/communication.md +108 -0
- package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
- package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
- package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
- package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
- package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
- package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
- package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
- package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
- package/skills/nobrainer-tech-flow/references/routing.md +125 -0
- package/skills/nobrainer-tech-flow/references/setup.md +180 -0
- package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
- package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
- package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
- package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
- package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
- package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
- package/skills/nobrainer-wiki/SKILL.md +164 -0
- package/skills/nobrainer-wiki/references/setup.md +88 -0
- package/skills/nobrainer-writing/SKILL.md +272 -0
- package/skills/nobrainer-writing/agents/openai.yaml +4 -0
- package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
- package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
- package/skills/nobrainer-writing/references/research.md +86 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect or explicitly set non-secret optional decision preferences."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT = {
|
|
11
|
+
"schema_version": 1,
|
|
12
|
+
"provider": "core",
|
|
13
|
+
"mode": "off",
|
|
14
|
+
"model": None,
|
|
15
|
+
"timeout_seconds": 10,
|
|
16
|
+
"max_calls_per_run": 0,
|
|
17
|
+
}
|
|
18
|
+
MODELS = {"core": None, "jev": "jev-latest", "laya": "aac6fef/laya-multilingual-mlx"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate(value):
|
|
22
|
+
if not isinstance(value, dict) or set(value) != set(DEFAULT):
|
|
23
|
+
raise ValueError("Configuration fields do not match schema version 1")
|
|
24
|
+
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
|
25
|
+
raise ValueError("Unsupported schema version")
|
|
26
|
+
if not isinstance(value["provider"], str) or value["provider"] not in MODELS or value["mode"] not in ("off", "shadow"):
|
|
27
|
+
raise ValueError("Unknown provider or mode")
|
|
28
|
+
if value["provider"] == "core":
|
|
29
|
+
if value["mode"] != "off" or value["model"] is not None or value["max_calls_per_run"] != 0:
|
|
30
|
+
raise ValueError("Core profile requires off mode, no model and zero calls")
|
|
31
|
+
elif not isinstance(value["model"], str) or not value["model"].strip():
|
|
32
|
+
raise ValueError("An optional provider requires a model identifier")
|
|
33
|
+
for field, low, high in (("timeout_seconds", 1, 120), ("max_calls_per_run", 0, 100)):
|
|
34
|
+
if type(value[field]) is not int or not low <= value[field] <= high:
|
|
35
|
+
raise ValueError(f"Invalid {field}")
|
|
36
|
+
if value["mode"] == "shadow" and value["max_calls_per_run"] == 0:
|
|
37
|
+
raise ValueError("Shadow mode requires a positive call limit")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read(path):
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return {"status": "DEFAULT_CORE", "configured": False, "config": dict(DEFAULT)}
|
|
44
|
+
try:
|
|
45
|
+
value = validate(json.loads(path.read_text(encoding="utf-8")))
|
|
46
|
+
return {"status": "CONFIGURED", "configured": True, "config": value}
|
|
47
|
+
except (OSError, ValueError, TypeError):
|
|
48
|
+
return {"status": "INVALID_CONFIG_CORE_FALLBACK", "configured": True, "config": dict(DEFAULT)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure(path, provider, mode, model, timeout, calls):
|
|
52
|
+
value = validate({"schema_version": 1, "provider": provider, "mode": mode,
|
|
53
|
+
"model": model, "timeout_seconds": timeout, "max_calls_per_run": calls})
|
|
54
|
+
if path.is_symlink():
|
|
55
|
+
raise ValueError("Refusing to replace a symlink configuration")
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".flow-config-")
|
|
58
|
+
try:
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
60
|
+
json.dump(value, stream, indent=2)
|
|
61
|
+
stream.write("\n")
|
|
62
|
+
os.replace(temporary, path)
|
|
63
|
+
finally:
|
|
64
|
+
if os.path.exists(temporary):
|
|
65
|
+
os.unlink(temporary)
|
|
66
|
+
return read(path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
71
|
+
parser.add_argument("--config", type=Path, default=Path(".nobrainer/flow.json"))
|
|
72
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
73
|
+
sub.add_parser("show")
|
|
74
|
+
setup = sub.add_parser("setup")
|
|
75
|
+
setup.add_argument("--non-interactive", action="store_true")
|
|
76
|
+
setter = sub.add_parser("set")
|
|
77
|
+
setter.add_argument("--provider", choices=tuple(MODELS), required=True)
|
|
78
|
+
setter.add_argument("--mode", choices=("off", "shadow"), default="off")
|
|
79
|
+
setter.add_argument("--model")
|
|
80
|
+
setter.add_argument("--timeout", type=int, default=10)
|
|
81
|
+
setter.add_argument("--max-calls", type=int, default=0)
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
try:
|
|
84
|
+
if args.command == "setup":
|
|
85
|
+
result = read(args.config)
|
|
86
|
+
if not result["configured"]:
|
|
87
|
+
import sys
|
|
88
|
+
choice = "core"
|
|
89
|
+
if not args.non_interactive and sys.stdin.isatty():
|
|
90
|
+
choice = input("Optional decisions: enter core (default), jev (remote), or laya (local): ").strip().lower() or "core"
|
|
91
|
+
if choice not in MODELS:
|
|
92
|
+
raise ValueError("Unknown choice")
|
|
93
|
+
# Remember provider choice without authorizing data or charges.
|
|
94
|
+
result = configure(args.config, choice, "off", MODELS[choice], 10, 0)
|
|
95
|
+
else:
|
|
96
|
+
result = read(args.config) if args.command == "show" else configure(
|
|
97
|
+
args.config, args.provider, args.mode, args.model or MODELS[args.provider],
|
|
98
|
+
args.timeout, args.max_calls)
|
|
99
|
+
except (ValueError, OSError):
|
|
100
|
+
print(json.dumps({"status": "CONFIG_NOT_SAVED"}))
|
|
101
|
+
return 2
|
|
102
|
+
print(json.dumps(result, indent=2))
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Optional Claude Code guards for nobrainer-tech-flow.
|
|
3
|
+
|
|
4
|
+
``pre-tool`` reads a PreToolUse payload and asks the person before a common risky
|
|
5
|
+
shell command; it never denies. ``stop`` reads a Stop or SubagentStop payload and
|
|
6
|
+
blocks the stop once when a turn that edited files or ran commands ends without a
|
|
7
|
+
proof line. Both fail open: input they cannot read is allowed, with a note on stderr.
|
|
8
|
+
|
|
9
|
+
This is a seatbelt, not a security boundary. It reads the command text, so a
|
|
10
|
+
command hidden behind ``sh -c``, a variable or an alias can pass unnoticed, and a
|
|
11
|
+
risky phrase inside a quoted string can ask without need.
|
|
12
|
+
|
|
13
|
+
Install with ``scripts/install_guards.py``; the field names follow
|
|
14
|
+
https://code.claude.com/docs/en/hooks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# One shell command: the text up to the next separator.
|
|
29
|
+
ARGS = r"[^;&|\n]*?"
|
|
30
|
+
# Global options between a tool and its subcommand, each "-x", "--name=value" or an
|
|
31
|
+
# option and one value word, as in "npm -w pkg publish" or "gh -R owner/repo release".
|
|
32
|
+
# Narrower than ARGS, so "npm run publish" or "gh issue create --title release" pass.
|
|
33
|
+
OPTIONS = r"(?:\s+-[^\s;&|]+(?:\s+[^\s;&|-][^\s;&|]*)?)*"
|
|
34
|
+
END = r"(?=\s|$|[;&|)])"
|
|
35
|
+
RISKY = (
|
|
36
|
+
# --force-with-lease and a "+branch" refspec ask too: they still rewrite the remote.
|
|
37
|
+
(re.compile(rf"\bgit\b{ARGS}\spush\b{ARGS}\s(?:--force\S*|-[a-zA-Z]*f[a-zA-Z]*|\+[A-Za-z]\S*){END}"),
|
|
38
|
+
"git push --force rewrites the remote branch"),
|
|
39
|
+
(re.compile(rf"\bgit\b{ARGS}\sreset\b{ARGS}\s--hard\b"), "git reset --hard discards uncommitted work"),
|
|
40
|
+
(re.compile(rf"\bgit\b{ARGS}\sclean\b{ARGS}\s(?:--force\b|-[a-zA-Z]*f)"),
|
|
41
|
+
"git clean -f deletes untracked files"),
|
|
42
|
+
(re.compile(rf"\bvercel\b{ARGS}\s--prod\b"), "vercel --prod deploys to production"),
|
|
43
|
+
(re.compile(rf"\bnpm\b{OPTIONS}\s+publish{END}"), "npm publish releases a package"),
|
|
44
|
+
(re.compile(rf"\bterraform\b{ARGS}\s(?:apply|destroy)\b"), "terraform apply or destroy changes infrastructure"),
|
|
45
|
+
(re.compile(rf"\bkubectl\b{ARGS}\sdelete\b"), "kubectl delete removes cluster resources"),
|
|
46
|
+
(re.compile(rf"\bgh\b{OPTIONS}\s+release\s+create{END}"), "gh release create publishes a release"),
|
|
47
|
+
)
|
|
48
|
+
# An rm, also path-qualified as /bin/rm or quoted as "rm", that is not part of "git rm";
|
|
49
|
+
# its arguments run to the next separator.
|
|
50
|
+
RM = re.compile(r"(?<![\w./-])[\"']?(?:[\w.-]*/)*rm[\"']?(?=\s)([^;&|\n)]*)")
|
|
51
|
+
# Commands are matched one segment at a time. A segment this long that names a risky
|
|
52
|
+
# tool is asked about whole, because the patterns slow down sharply on long lines.
|
|
53
|
+
SEPARATOR = re.compile(r"[;&|\n]")
|
|
54
|
+
LONGEST_SEGMENT = 2000
|
|
55
|
+
RISKY_TOOL = re.compile(r"\b(?:git|vercel|npm|terraform|kubectl|gh)\b")
|
|
56
|
+
# A directory change before an rm means the hook's cwd no longer says where a relative
|
|
57
|
+
# target is, so the temporary-folder exemption cannot apply to it.
|
|
58
|
+
CHANGE_DIRECTORY = re.compile(r"(?<![\w./-])(?:cd|pushd|popd)(?=\s|$|[;&|)])")
|
|
59
|
+
RECURSIVE = re.compile(r"^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$")
|
|
60
|
+
TEMP_ROOTS = ("/tmp", "/private/tmp", "/var/folders", "/private/var/folders")
|
|
61
|
+
TMPDIR_REFERENCE = re.compile(r"^(?:\$TMPDIR|\$\{TMPDIR\})(?=/|$)")
|
|
62
|
+
EDITING_TOOLS = {"Edit", "MultiEdit", "Write", "NotebookEdit", "Bash"}
|
|
63
|
+
# A proof line may sit in a bulleted or numbered list item, with the label in bold.
|
|
64
|
+
PROOF_LINE = re.compile(r"^[\s>*_\-]*(?:\d+[.)]\s*)?(?:\*\*|__)?(?:Proof|Checked)(?:\*\*|__)?:", re.MULTILINE)
|
|
65
|
+
STOP_REASON = (
|
|
66
|
+
"Flow guard: this turn changed files or ran commands, but the final message has no "
|
|
67
|
+
"proof. Add a line starting with 'Proof:' or 'Checked:' that names the command, test "
|
|
68
|
+
"or readback showing the result, or say UNVERIFIED and what is missing."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def note(message: str) -> None:
|
|
73
|
+
print(f"flow_guard: {message}", file=sys.stderr)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def temp_roots() -> list[str]:
|
|
77
|
+
roots = list(TEMP_ROOTS)
|
|
78
|
+
for value in (os.environ.get("TMPDIR"), tempfile.gettempdir()):
|
|
79
|
+
if value and os.path.isabs(value):
|
|
80
|
+
roots.append(os.path.normpath(value))
|
|
81
|
+
# Compared with real paths below, so each root counts in its real form as well
|
|
82
|
+
# (/tmp is /private/tmp on macOS).
|
|
83
|
+
return sorted({form for root in roots for form in (root, os.path.realpath(root))})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def inside_temp(argument: str, cwd: str | None, roots: list[str]) -> bool:
|
|
87
|
+
"""Whether ARGUMENT names a path strictly inside a temporary folder."""
|
|
88
|
+
|
|
89
|
+
tmpdir = os.environ.get("TMPDIR") or tempfile.gettempdir()
|
|
90
|
+
argument = TMPDIR_REFERENCE.sub(lambda _: tmpdir, argument)
|
|
91
|
+
if "$" in argument or "`" in argument or argument.startswith("~"):
|
|
92
|
+
return False
|
|
93
|
+
if not os.path.isabs(argument):
|
|
94
|
+
if not cwd or not os.path.isabs(cwd):
|
|
95
|
+
return False
|
|
96
|
+
argument = os.path.join(cwd, argument)
|
|
97
|
+
# Links on the way are followed before any "..", as the shell does: a folder under
|
|
98
|
+
# /tmp that links to a project is the project, and its real place decides.
|
|
99
|
+
path = os.path.realpath(argument)
|
|
100
|
+
return any(path.startswith(root.rstrip("/") + "/") for root in roots)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def risky_rm(command: str, cwd: str | None) -> str | None:
|
|
104
|
+
roots = temp_roots()
|
|
105
|
+
for match in RM.finditer(command):
|
|
106
|
+
before = SEPARATOR.split(command[: match.start()])[-1].split()
|
|
107
|
+
if before and os.path.basename(before[-1]) == "git":
|
|
108
|
+
continue
|
|
109
|
+
base = None if CHANGE_DIRECTORY.search(command, 0, match.start()) else cwd
|
|
110
|
+
try:
|
|
111
|
+
words = shlex.split(match.group(1))
|
|
112
|
+
except ValueError:
|
|
113
|
+
# An open quote means a separator inside it cut the arguments short, or the
|
|
114
|
+
# "rm" is only a word in quoted text. Only a recursive rm is asked about.
|
|
115
|
+
rest = command[match.start(1):].split("\n")[0].split()
|
|
116
|
+
if any(RECURSIVE.match(word.strip("\"'")) for word in rest):
|
|
117
|
+
return "rm -r with arguments the guard cannot read deletes files for good"
|
|
118
|
+
continue
|
|
119
|
+
if not any(RECURSIVE.match(word) for word in words):
|
|
120
|
+
continue
|
|
121
|
+
paths = [word for word in words if not word.startswith("-")]
|
|
122
|
+
if not paths or not all(inside_temp(path, base, roots) for path in paths):
|
|
123
|
+
return "rm -r outside a temporary folder deletes files for good"
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pre_tool(payload: dict) -> dict | None:
|
|
128
|
+
if payload.get("tool_name") != "Bash":
|
|
129
|
+
return None
|
|
130
|
+
tool_input = payload.get("tool_input")
|
|
131
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
132
|
+
if not isinstance(command, str):
|
|
133
|
+
return None
|
|
134
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
|
|
135
|
+
# A backslash line continuation is one command, so join it before splitting.
|
|
136
|
+
command = command.replace("\\\n", " ")
|
|
137
|
+
reasons = []
|
|
138
|
+
for segment in SEPARATOR.split(command):
|
|
139
|
+
if len(segment) > LONGEST_SEGMENT and RISKY_TOOL.search(segment):
|
|
140
|
+
reasons.append("a long command names a risky tool and is too long to check")
|
|
141
|
+
continue
|
|
142
|
+
reasons += [reason for pattern, reason in RISKY if pattern.search(segment) and reason not in reasons]
|
|
143
|
+
rm = risky_rm(command, cwd)
|
|
144
|
+
if rm:
|
|
145
|
+
reasons.insert(0, rm)
|
|
146
|
+
if not reasons:
|
|
147
|
+
return None
|
|
148
|
+
return {
|
|
149
|
+
"hookSpecificOutput": {
|
|
150
|
+
"hookEventName": "PreToolUse",
|
|
151
|
+
"permissionDecision": "ask",
|
|
152
|
+
"permissionDecisionReason": (
|
|
153
|
+
f"Flow guard: {'; '.join(reasons)}. Confirm only if this is intended "
|
|
154
|
+
"(a seatbelt check, not a security boundary)."
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def blocks(entry: dict) -> list:
|
|
161
|
+
message = entry.get("message")
|
|
162
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
163
|
+
if isinstance(content, str):
|
|
164
|
+
return [{"type": "text", "text": content}]
|
|
165
|
+
return [block for block in content if isinstance(block, dict)] if isinstance(content, list) else []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_turn(path: Path, skip_sidechain: bool) -> tuple[set[str], str]:
|
|
169
|
+
"""The tools the last turn used and its final assistant text.
|
|
170
|
+
|
|
171
|
+
A turn starts at the last user entry that is a prompt rather than tool results.
|
|
172
|
+
Lines that are not JSON (a line still being written) are skipped.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
entries = []
|
|
176
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
177
|
+
for line in handle:
|
|
178
|
+
try:
|
|
179
|
+
entry = json.loads(line)
|
|
180
|
+
except ValueError:
|
|
181
|
+
continue
|
|
182
|
+
if not isinstance(entry, dict) or entry.get("type") not in ("user", "assistant"):
|
|
183
|
+
continue
|
|
184
|
+
if skip_sidechain and entry.get("isSidechain"):
|
|
185
|
+
continue
|
|
186
|
+
entries.append(entry)
|
|
187
|
+
if not entries:
|
|
188
|
+
raise ValueError(f"no transcript entries in {path}")
|
|
189
|
+
start = 0
|
|
190
|
+
for index, entry in enumerate(entries):
|
|
191
|
+
if entry["type"] == "user" and not any(block.get("type") == "tool_result" for block in blocks(entry)):
|
|
192
|
+
start = index
|
|
193
|
+
tools: set[str] = set()
|
|
194
|
+
final = ""
|
|
195
|
+
for entry in entries[start:]:
|
|
196
|
+
if entry["type"] != "assistant":
|
|
197
|
+
continue
|
|
198
|
+
for block in blocks(entry):
|
|
199
|
+
if block.get("type") == "tool_use" and isinstance(block.get("name"), str):
|
|
200
|
+
tools.add(block["name"])
|
|
201
|
+
text = "\n".join(
|
|
202
|
+
block["text"] for block in blocks(entry) if block.get("type") == "text" and isinstance(block.get("text"), str)
|
|
203
|
+
)
|
|
204
|
+
if text.strip():
|
|
205
|
+
final = text
|
|
206
|
+
return tools, final
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def stop(payload: dict) -> dict | None:
|
|
210
|
+
if payload.get("stop_hook_active") is True:
|
|
211
|
+
return None
|
|
212
|
+
event = payload.get("hook_event_name")
|
|
213
|
+
transcript = payload.get("agent_transcript_path") if event == "SubagentStop" else None
|
|
214
|
+
transcript = transcript or payload.get("transcript_path")
|
|
215
|
+
if not isinstance(transcript, str) or not transcript:
|
|
216
|
+
note("no transcript path in the payload; allowing the stop")
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
tools, final = read_turn(Path(transcript).expanduser(), skip_sidechain=event != "SubagentStop")
|
|
220
|
+
except (OSError, ValueError) as exc:
|
|
221
|
+
note(f"cannot read the transcript ({exc}); allowing the stop")
|
|
222
|
+
return None
|
|
223
|
+
if not tools & EDITING_TOOLS:
|
|
224
|
+
return None
|
|
225
|
+
message = payload.get("last_assistant_message")
|
|
226
|
+
text = message if isinstance(message, str) else final
|
|
227
|
+
if PROOF_LINE.search(text) or "UNVERIFIED" in text:
|
|
228
|
+
return None
|
|
229
|
+
return {"decision": "block", "reason": STOP_REASON}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main(argv: list[str] | None = None) -> int:
|
|
233
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
234
|
+
if len(argv) != 1 or argv[0] not in ("pre-tool", "stop"):
|
|
235
|
+
print("usage: flow_guard.py pre-tool|stop (reads the hook payload on stdin)", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
try:
|
|
238
|
+
payload = json.loads(sys.stdin.read())
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
note(f"cannot parse the hook input ({exc}); allowing")
|
|
241
|
+
return 0
|
|
242
|
+
if not isinstance(payload, dict):
|
|
243
|
+
note("the hook input is not a JSON object; allowing")
|
|
244
|
+
return 0
|
|
245
|
+
result = pre_tool(payload) if argv[0] == "pre-tool" else stop(payload)
|
|
246
|
+
if result is not None:
|
|
247
|
+
print(json.dumps(result))
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bounded advisory typed decisions; never executes selected actions."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from decision_config import read
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def finite(value, low, high):
|
|
18
|
+
return type(value) in (int, float) and math.isfinite(value) and low <= value <= high
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_request(request):
|
|
22
|
+
if not isinstance(request, dict) or set(request) != {"state", "questions"}:
|
|
23
|
+
raise ValueError("request_fields")
|
|
24
|
+
if not isinstance(request["state"], (str, dict, list)):
|
|
25
|
+
raise ValueError("state")
|
|
26
|
+
questions = request["questions"]
|
|
27
|
+
if not isinstance(questions, dict) or not 1 <= len(questions) <= 16:
|
|
28
|
+
raise ValueError("questions")
|
|
29
|
+
for key, question in questions.items():
|
|
30
|
+
if not isinstance(key, str) or not key or not isinstance(question, dict):
|
|
31
|
+
raise ValueError("question")
|
|
32
|
+
if not isinstance(question.get("instructions"), (str, dict, list)) or not question["instructions"]:
|
|
33
|
+
raise ValueError("instructions")
|
|
34
|
+
kind = question.get("type")
|
|
35
|
+
criteria = question.get("criteria")
|
|
36
|
+
if kind == "choice":
|
|
37
|
+
if not isinstance(criteria, dict) or not criteria or not all(isinstance(k, str) and k for k in criteria):
|
|
38
|
+
raise ValueError("choice_criteria")
|
|
39
|
+
elif kind == "score":
|
|
40
|
+
if not isinstance(criteria, list) or len(criteria) < 2:
|
|
41
|
+
raise ValueError("score_criteria")
|
|
42
|
+
elif kind != "noul":
|
|
43
|
+
raise ValueError("question_type")
|
|
44
|
+
return request
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_answers(request, response):
|
|
48
|
+
answers = response.get("answers") if isinstance(response, dict) else None
|
|
49
|
+
if not isinstance(answers, dict) or set(answers) != set(request["questions"]):
|
|
50
|
+
raise ValueError("answer_ids")
|
|
51
|
+
clean = {}
|
|
52
|
+
for key, question in request["questions"].items():
|
|
53
|
+
answer = answers[key]
|
|
54
|
+
kind = question["type"]
|
|
55
|
+
if not isinstance(answer, dict) or answer.get("type") != kind:
|
|
56
|
+
raise ValueError("answer_type")
|
|
57
|
+
if kind == "noul":
|
|
58
|
+
if not finite(answer.get("noul"), 0, 1):
|
|
59
|
+
raise ValueError("noul")
|
|
60
|
+
clean[key] = {"type": kind, "noul": answer["noul"]}
|
|
61
|
+
continue
|
|
62
|
+
keys = set(question["criteria"]) if kind == "choice" else {str(i) for i in range(len(question["criteria"]))}
|
|
63
|
+
probabilities = answer.get("probabilities")
|
|
64
|
+
if not isinstance(probabilities, dict) or set(probabilities) != keys:
|
|
65
|
+
raise ValueError("distribution_keys")
|
|
66
|
+
if not all(finite(p, 0, 1) for p in probabilities.values()) or abs(sum(probabilities.values()) - 1) > 0.02:
|
|
67
|
+
raise ValueError("distribution_values")
|
|
68
|
+
if not finite(answer.get("confidence"), 0, 1):
|
|
69
|
+
raise ValueError("confidence")
|
|
70
|
+
value = answer.get(kind)
|
|
71
|
+
if kind == "choice" and (not isinstance(value, str) or value not in keys):
|
|
72
|
+
raise ValueError("choice")
|
|
73
|
+
if kind == "score" and not finite(value, 0, len(keys) - 1):
|
|
74
|
+
raise ValueError("score")
|
|
75
|
+
clean[key] = {"type": kind, kind: value, "confidence": answer["confidence"], "probabilities": probabilities}
|
|
76
|
+
return clean
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
80
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
81
|
+
raise ValueError("redirect_not_allowed")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def worker(provider, model, request):
|
|
85
|
+
if provider == "jev":
|
|
86
|
+
key = os.environ.get("TYPESAFE_API_KEY")
|
|
87
|
+
if not key:
|
|
88
|
+
return {"error": "MISSING_TYPESAFE_API_KEY"}
|
|
89
|
+
payload = json.dumps(dict(request, model=model)).encode()
|
|
90
|
+
req = urllib.request.Request("https://api.typesafe.ai/v1/systemone", data=payload,
|
|
91
|
+
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
|
|
92
|
+
with urllib.request.build_opener(NoRedirect).open(req, timeout=120) as response:
|
|
93
|
+
raw = response.read(1_000_001)
|
|
94
|
+
if len(raw) > 1_000_000:
|
|
95
|
+
return {"error": "RESPONSE_TOO_LARGE"}
|
|
96
|
+
return json.loads(raw)
|
|
97
|
+
if provider != "laya":
|
|
98
|
+
return {"error": "UNKNOWN_PROVIDER"}
|
|
99
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
100
|
+
return {"error": "UNSUPPORTED_LOCAL_RUNTIME"}
|
|
101
|
+
# Never fetch weights implicitly: model must already be available offline.
|
|
102
|
+
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
103
|
+
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
104
|
+
try:
|
|
105
|
+
import laya_mlx
|
|
106
|
+
from huggingface_hub import snapshot_download
|
|
107
|
+
except ImportError:
|
|
108
|
+
return {"error": "MISSING_LOCAL_RUNTIME"}
|
|
109
|
+
if not Path(model).is_dir():
|
|
110
|
+
try:
|
|
111
|
+
model_path = snapshot_download(model, local_files_only=True, allow_patterns=[
|
|
112
|
+
"model.safetensors", "rl_agent_config.json", "encoder/config.json", "tokenizer/*", "mlx_config.json"])
|
|
113
|
+
except Exception:
|
|
114
|
+
return {"error": "MISSING_LOCAL_MODEL"}
|
|
115
|
+
else:
|
|
116
|
+
model_path = model
|
|
117
|
+
try:
|
|
118
|
+
agent = laya_mlx.load(model_path, dtype="float16")
|
|
119
|
+
except Exception:
|
|
120
|
+
return {"error": "INCOMPATIBLE_LOCAL_MODEL"}
|
|
121
|
+
result = agent.predict(request["state"], request["questions"])
|
|
122
|
+
result["model"] = model
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def invoke(provider, model, request, timeout):
|
|
127
|
+
env = {k: v for k, v in os.environ.items()
|
|
128
|
+
if not any(part in k.upper() for part in ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL"))}
|
|
129
|
+
if provider == "jev" and os.environ.get("TYPESAFE_API_KEY"):
|
|
130
|
+
env["TYPESAFE_API_KEY"] = os.environ["TYPESAFE_API_KEY"]
|
|
131
|
+
process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", provider, model],
|
|
132
|
+
input=json.dumps(request), capture_output=True, text=True,
|
|
133
|
+
timeout=timeout, env=env)
|
|
134
|
+
if process.returncode:
|
|
135
|
+
raise ValueError("provider_failed")
|
|
136
|
+
return json.loads(process.stdout)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def evaluate(requests, configuration, approved=False, call=invoke):
|
|
140
|
+
cfg = configuration["config"]
|
|
141
|
+
results = []
|
|
142
|
+
attempted = 0
|
|
143
|
+
for request in requests:
|
|
144
|
+
reason = None
|
|
145
|
+
if configuration["status"] == "INVALID_CONFIG_CORE_FALLBACK":
|
|
146
|
+
reason = "INVALID_CONFIG"
|
|
147
|
+
elif cfg["mode"] == "off" or cfg["provider"] == "core":
|
|
148
|
+
reason = "OFF"
|
|
149
|
+
elif not approved:
|
|
150
|
+
reason = "DATA_NOT_APPROVED"
|
|
151
|
+
elif attempted >= cfg["max_calls_per_run"]:
|
|
152
|
+
reason = "CALL_LIMIT"
|
|
153
|
+
started = time.monotonic()
|
|
154
|
+
result = {"status": "CORE_FALLBACK", "provider": cfg["provider"], "model": cfg["model"]}
|
|
155
|
+
if reason is None:
|
|
156
|
+
try:
|
|
157
|
+
validate_request(request)
|
|
158
|
+
attempted += 1
|
|
159
|
+
response = call(cfg["provider"], cfg["model"], request, cfg["timeout_seconds"])
|
|
160
|
+
if isinstance(response, dict) and response.get("error"):
|
|
161
|
+
allowed = {"MISSING_TYPESAFE_API_KEY", "UNSUPPORTED_LOCAL_RUNTIME", "MISSING_LOCAL_RUNTIME",
|
|
162
|
+
"MISSING_LOCAL_MODEL", "INCOMPATIBLE_LOCAL_MODEL", "UNKNOWN_PROVIDER"}
|
|
163
|
+
reason = response["error"] if isinstance(response["error"], str) and response["error"] in allowed else "INVALID_OR_UNAVAILABLE"
|
|
164
|
+
else:
|
|
165
|
+
result["answers"] = validate_answers(request, response)
|
|
166
|
+
result["status"] = "SHADOW_SUGGESTION"
|
|
167
|
+
model = response.get("model")
|
|
168
|
+
result["model"] = model if isinstance(model, str) and len(model) <= 128 else cfg["model"]
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
reason = "TIMEOUT"
|
|
171
|
+
except (ValueError, TypeError, KeyError, OSError):
|
|
172
|
+
reason = "INVALID_OR_UNAVAILABLE"
|
|
173
|
+
result.update(reason=reason, calls_used=attempted,
|
|
174
|
+
elapsed_seconds=round(time.monotonic() - started, 6))
|
|
175
|
+
results.append(result)
|
|
176
|
+
return {"status": "ADVISORY_ONLY", "calls_used": attempted, "results": results}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main():
|
|
180
|
+
if len(sys.argv) == 4 and sys.argv[1] == "--worker":
|
|
181
|
+
try:
|
|
182
|
+
# Third-party stdout may contain progress or input; contain it.
|
|
183
|
+
import contextlib
|
|
184
|
+
import io
|
|
185
|
+
request = validate_request(json.load(sys.stdin))
|
|
186
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
187
|
+
result = worker(sys.argv[2], sys.argv[3], request)
|
|
188
|
+
print(json.dumps(result))
|
|
189
|
+
return 0
|
|
190
|
+
except Exception:
|
|
191
|
+
print(json.dumps({"error": "PROVIDER_UNAVAILABLE"}))
|
|
192
|
+
return 2
|
|
193
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
194
|
+
parser.add_argument("--config", type=Path, default=Path(".nobrainer/flow.json"))
|
|
195
|
+
parser.add_argument("--request", type=Path, help="JSON array of requests; otherwise stdin")
|
|
196
|
+
parser.add_argument("--approved-data", action="store_true", help="Only with existing approval for this data and provider")
|
|
197
|
+
args = parser.parse_args()
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(args.request.read_text()) if args.request else json.load(sys.stdin)
|
|
200
|
+
if not isinstance(data, list) or len(data) > 100:
|
|
201
|
+
raise ValueError("batch")
|
|
202
|
+
print(json.dumps(evaluate(data, read(args.config), args.approved_data), ensure_ascii=False))
|
|
203
|
+
return 0
|
|
204
|
+
except (OSError, ValueError, TypeError):
|
|
205
|
+
print(json.dumps({"status": "CORE_FALLBACK", "reason": "INVALID_INPUT"}))
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
raise SystemExit(main())
|