create-byan-agent 2.15.0 → 2.17.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/CHANGELOG.md +78 -0
- package/README.md +24 -0
- package/install/GUIDE-INSTALLATION-BYAN-SIMPLE.md +40 -0
- package/install/bin/create-byan-agent-v2.js +9 -0
- package/install/lib/claude-native-setup.js +37 -0
- package/install/lib/mcp-extensions/gdrive.js +256 -0
- package/install/lib/mcp-extensions/index.js +147 -0
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/mcp-config.js +107 -8
- package/install/packages/platform-config/lib/validate.js +0 -14
- package/install/src/webui/api.js +6 -0
- package/install/src/webui/server.js +8 -1
- package/install/templates/.claude/CLAUDE.md +18 -0
- package/install/templates/.claude/hooks/lib/strict-config.json +46 -0
- package/install/templates/.claude/hooks/lib/strict-runtime.js +82 -0
- package/install/templates/.claude/hooks/strict-context-inject.js +86 -0
- package/install/templates/.claude/hooks/strict-scope-guard.js +101 -0
- package/install/templates/.claude/hooks/strict-stop-guard.js +100 -0
- package/install/templates/.claude/rules/strict-mode.md +166 -0
- package/install/templates/.claude/settings.json +12 -0
- package/install/templates/.claude/skills/byan-strict/SKILL.md +54 -0
- package/install/templates/.githooks/pre-commit +15 -0
- package/install/templates/_byan/_config/strict-mode.yaml +258 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-sync-rules.js +24 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/strict-precommit-gate.js +21 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +2 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +120 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-activation.js +76 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +391 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-sync.js +140 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +261 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +207 -1
- package/package.json +6 -2
- package/src/byan-v2/data/strict-mantras.json +188 -0
- package/src/byan-v2/generation/mantra-validator.js +39 -4
- package/update-byan-agent/__tests__/migrate-mcp-config.test.js +74 -24
- package/update-byan-agent/lib/migrate-mcp-config.js +33 -27
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* READ-MERGE-WRITE semantics : preserves all existing mcpServers.* entries
|
|
5
5
|
* and, if byan entry already exists, preserves its command/args. Only the
|
|
6
|
-
* env.BYAN_API_URL
|
|
6
|
+
* env.BYAN_API_URL is authoritative from caller.
|
|
7
|
+
*
|
|
8
|
+
* Security: BYAN_API_TOKEN is NEVER written into .mcp.json (which is
|
|
9
|
+
* checked into git). The token lives exclusively in:
|
|
10
|
+
* - .env (gitignored, for shell tools and Codex CLI)
|
|
11
|
+
* - .claude/settings.local.json (gitignored, for Claude Code MCP injection)
|
|
12
|
+
*
|
|
13
|
+
* Claude Code reads .claude/settings.local.json's "env" block at startup and
|
|
14
|
+
* injects those vars into every MCP server it spawns. So the token reaches
|
|
15
|
+
* the byan MCP server via that channel — no need to declare it in .mcp.json.
|
|
16
|
+
*
|
|
17
|
+
* The `token` parameter on this module's API is kept for backward-compat
|
|
18
|
+
* but is intentionally discarded (with a one-line audit trail in the
|
|
19
|
+
* returned result). Callers that supply a token should instead use
|
|
20
|
+
* envConfig.updateDotenv + envConfig.updateSettingsLocal.
|
|
7
21
|
*/
|
|
8
22
|
|
|
9
23
|
const path = require('path');
|
|
@@ -11,6 +25,7 @@ const fs = require('fs-extra');
|
|
|
11
25
|
const { stripApiSuffix } = require('./url-utils');
|
|
12
26
|
|
|
13
27
|
const MCP_SERVER_REL_PATH = '_byan/mcp/byan-mcp-server/server.js';
|
|
28
|
+
const TOKEN_PLACEHOLDER = '${BYAN_API_TOKEN}';
|
|
14
29
|
|
|
15
30
|
async function readJsonOrEmpty(filePath) {
|
|
16
31
|
if (await fs.pathExists(filePath)) {
|
|
@@ -43,11 +58,14 @@ async function readMcpConfig(projectRoot) {
|
|
|
43
58
|
* Pure merge — no I/O. Returns a new config object with byan entry merged.
|
|
44
59
|
* Useful for migrations that inspect the diff before writing.
|
|
45
60
|
*
|
|
61
|
+
* BYAN_API_TOKEN is intentionally stripped from env (never written into
|
|
62
|
+
* .mcp.json). See module header for the rationale and the persistence path.
|
|
63
|
+
*
|
|
46
64
|
* @param {object} existingConfig — current parsed config (may be {} or {mcpServers:{...}})
|
|
47
|
-
* @param {{ apiUrl: string, token?: string }} opts
|
|
65
|
+
* @param {{ apiUrl: string, token?: string }} opts — `token` is accepted but discarded
|
|
48
66
|
* @returns {object} new merged config
|
|
49
67
|
*/
|
|
50
|
-
function mergeByanEntry(existingConfig, { apiUrl
|
|
68
|
+
function mergeByanEntry(existingConfig, { apiUrl } = {}) {
|
|
51
69
|
const cfg = existingConfig && typeof existingConfig === 'object' ? { ...existingConfig } : {};
|
|
52
70
|
cfg.mcpServers = { ...(cfg.mcpServers || {}) };
|
|
53
71
|
|
|
@@ -56,11 +74,7 @@ function mergeByanEntry(existingConfig, { apiUrl, token } = {}) {
|
|
|
56
74
|
|
|
57
75
|
const env = { ...(existing.env || {}) };
|
|
58
76
|
env.BYAN_API_URL = cleanUrl;
|
|
59
|
-
|
|
60
|
-
env.BYAN_API_TOKEN = token;
|
|
61
|
-
} else {
|
|
62
|
-
delete env.BYAN_API_TOKEN;
|
|
63
|
-
}
|
|
77
|
+
delete env.BYAN_API_TOKEN;
|
|
64
78
|
|
|
65
79
|
cfg.mcpServers.byan = {
|
|
66
80
|
command: 'node',
|
|
@@ -87,9 +101,94 @@ async function ensureMcpConfig(projectRoot, { apiUrl, token } = {}) {
|
|
|
87
101
|
return { path: filePath };
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Adds (or replaces) an arbitrary MCP server entry under mcpServers.<name>.
|
|
106
|
+
* Used by mcp-extensions to register third-party MCPs (gdrive, etc.) without
|
|
107
|
+
* touching the byan entry. Other entries are preserved.
|
|
108
|
+
*
|
|
109
|
+
* Security guard : refuses to write any value matching common token shapes
|
|
110
|
+
* directly into .mcp.json. Callers must put secrets in .env / settings.local
|
|
111
|
+
* and reference env-var names elsewhere.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} projectRoot
|
|
114
|
+
* @param {string} name — server identifier (becomes the mcpServers key)
|
|
115
|
+
* @param {object} entry — { command, args?, env?, ... }
|
|
116
|
+
* @returns {Promise<{ path: string }>}
|
|
117
|
+
*/
|
|
118
|
+
async function addMcpEntry(projectRoot, name, entry) {
|
|
119
|
+
if (!name || typeof name !== 'string') {
|
|
120
|
+
throw new Error('addMcpEntry: name must be a non-empty string');
|
|
121
|
+
}
|
|
122
|
+
if (!entry || typeof entry !== 'object') {
|
|
123
|
+
throw new Error('addMcpEntry: entry must be an object');
|
|
124
|
+
}
|
|
125
|
+
assertNoSecretInEntry(entry);
|
|
126
|
+
|
|
127
|
+
const filePath = path.join(projectRoot, '.mcp.json');
|
|
128
|
+
const current = await readJsonOrEmpty(filePath);
|
|
129
|
+
const cfg = current && typeof current === 'object' ? { ...current } : {};
|
|
130
|
+
cfg.mcpServers = { ...(cfg.mcpServers || {}) };
|
|
131
|
+
cfg.mcpServers[name] = entry;
|
|
132
|
+
await fs.writeJson(filePath, cfg, { spaces: 2 });
|
|
133
|
+
return { path: filePath };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Removes an MCP server entry. No-op if the entry does not exist.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} projectRoot
|
|
140
|
+
* @param {string} name
|
|
141
|
+
* @returns {Promise<{ path: string, removed: boolean }>}
|
|
142
|
+
*/
|
|
143
|
+
async function removeMcpEntry(projectRoot, name) {
|
|
144
|
+
const filePath = path.join(projectRoot, '.mcp.json');
|
|
145
|
+
const current = await readJsonOrEmpty(filePath);
|
|
146
|
+
if (!current || !current.mcpServers || !(name in current.mcpServers)) {
|
|
147
|
+
return { path: filePath, removed: false };
|
|
148
|
+
}
|
|
149
|
+
const cfg = { ...current, mcpServers: { ...current.mcpServers } };
|
|
150
|
+
delete cfg.mcpServers[name];
|
|
151
|
+
await fs.writeJson(filePath, cfg, { spaces: 2 });
|
|
152
|
+
return { path: filePath, removed: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SECRET_SHAPES = [
|
|
156
|
+
/^byan_[a-f0-9]{20,}$/i, // byan tokens
|
|
157
|
+
/^ghp_[A-Za-z0-9]{30,}$/, // GitHub PAT
|
|
158
|
+
/^gho_[A-Za-z0-9]{30,}$/, // GitHub OAuth
|
|
159
|
+
/^github_pat_[A-Za-z0-9_]{60,}$/, // GitHub PAT (new format)
|
|
160
|
+
/^sk-ant-[A-Za-z0-9_-]{20,}$/, // Anthropic
|
|
161
|
+
/^sk-proj-[A-Za-z0-9_-]{20,}$/, // OpenAI project
|
|
162
|
+
/^AIza[0-9A-Za-z_-]{30,}$/, // Google API key
|
|
163
|
+
/^xox[bpao]-[0-9]+-[0-9]+-/, // Slack
|
|
164
|
+
/^AKIA[0-9A-Z]{16}$/, // AWS
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
function looksLikeSecret(value) {
|
|
168
|
+
if (typeof value !== 'string') return false;
|
|
169
|
+
return SECRET_SHAPES.some((re) => re.test(value));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertNoSecretInEntry(entry) {
|
|
173
|
+
const env = (entry && entry.env) || {};
|
|
174
|
+
for (const [key, val] of Object.entries(env)) {
|
|
175
|
+
if (looksLikeSecret(val)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`addMcpEntry: env.${key} looks like a secret. Refusing to write it into .mcp.json. ` +
|
|
178
|
+
`Put the value in .env (gitignored) and reference it via .claude/settings.local.json env, ` +
|
|
179
|
+
`or use \${VAR_NAME} if your MCP client supports env-var expansion.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
90
185
|
module.exports = {
|
|
91
186
|
ensureMcpConfig,
|
|
92
187
|
readMcpConfig,
|
|
93
188
|
mergeByanEntry,
|
|
189
|
+
addMcpEntry,
|
|
190
|
+
removeMcpEntry,
|
|
191
|
+
looksLikeSecret,
|
|
94
192
|
MCP_SERVER_REL_PATH,
|
|
193
|
+
TOKEN_PLACEHOLDER,
|
|
95
194
|
};
|
|
@@ -24,20 +24,6 @@ async function validateByanWebReachability({ apiUrl, token, timeoutMs = 5000 })
|
|
|
24
24
|
const latencyMs = Date.now() - t0;
|
|
25
25
|
clearTimeout(timer);
|
|
26
26
|
|
|
27
|
-
const ct = (res.headers && typeof res.headers.get === 'function'
|
|
28
|
-
? res.headers.get('content-type')
|
|
29
|
-
: '') || '';
|
|
30
|
-
const lowerCt = ct.toLowerCase();
|
|
31
|
-
|
|
32
|
-
if (lowerCt.includes('text/html')) {
|
|
33
|
-
return {
|
|
34
|
-
reachable: false,
|
|
35
|
-
status: res.status,
|
|
36
|
-
latencyMs,
|
|
37
|
-
error: 'Response is HTML — BYAN_API_URL likely points at the WebUI (behind SSO) instead of the API backend. Try byan-api.<domain> without /api suffix.',
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
27
|
if (res.status >= 200 && res.status < 400) {
|
|
42
28
|
return { reachable: true, status: res.status, latencyMs };
|
|
43
29
|
}
|
package/install/src/webui/api.js
CHANGED
|
@@ -84,6 +84,12 @@ function detectPlatforms(projectRoot) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const routes = {
|
|
87
|
+
// Lightweight health-check — used by Electron LocalServer health-ping every 5s.
|
|
88
|
+
'GET health': async (req, res) => {
|
|
89
|
+
res.writeHead(200);
|
|
90
|
+
res.end(JSON.stringify({ ok: true }));
|
|
91
|
+
},
|
|
92
|
+
|
|
87
93
|
'GET status': async (req, res, server) => {
|
|
88
94
|
const projectRoot = server.projectRoot;
|
|
89
95
|
const version = readPackageVersion();
|
|
@@ -51,8 +51,15 @@ class ByanWebUI {
|
|
|
51
51
|
|
|
52
52
|
return new Promise((resolve) => {
|
|
53
53
|
this.server.listen(this.port, () => {
|
|
54
|
-
const
|
|
54
|
+
const addr = this.server.address();
|
|
55
|
+
const assignedPort = (addr && typeof addr === 'object') ? addr.port : this.port;
|
|
56
|
+
const url = `http://localhost:${assignedPort}`;
|
|
55
57
|
console.log(`BYAN WebUI running at ${url}`);
|
|
58
|
+
// Notify parent Electron process (F3 LocalServer) of the assigned port.
|
|
59
|
+
// process.send exists only when forked via child_process.fork().
|
|
60
|
+
if (typeof process.send === 'function') {
|
|
61
|
+
process.send({ type: 'ready', port: assignedPort });
|
|
62
|
+
}
|
|
56
63
|
this.openBrowser(url);
|
|
57
64
|
resolve(this);
|
|
58
65
|
});
|
|
@@ -44,6 +44,7 @@ Voir @.claude/rules/hermes-dispatcher.md pour les commandes Hermes.
|
|
|
44
44
|
- Methodologie: voir @.claude/rules/merise-agile.md
|
|
45
45
|
- Systeme de confiance epistemique: voir @.claude/rules/elo-trust.md
|
|
46
46
|
- Protocol fact-check scientifique: voir @.claude/rules/fact-check.md
|
|
47
|
+
- Mode strict anti-downgrade: voir @.claude/rules/strict-mode.md
|
|
47
48
|
- Systeme API byan_web: voir @.claude/rules/byan-api.md
|
|
48
49
|
|
|
49
50
|
## API byan_web
|
|
@@ -73,3 +74,20 @@ Domaines stricts : security/performance/compliance → LEVEL-2 minimum sinon BLO
|
|
|
73
74
|
|
|
74
75
|
Agent dédié: `@fact-checker` — analyse assertions, audits de documents, chaines de raisonnement.
|
|
75
76
|
Dans BYAN: tapez `[FC]` pour le sous-menu fact-check.
|
|
77
|
+
|
|
78
|
+
## BYAN Strict Mode
|
|
79
|
+
|
|
80
|
+
Mode d'enforcement anti-downgrade : empeche l'agent de livrer moins que demande
|
|
81
|
+
(MVP au lieu de prod, stub au lieu de feature, template bacle). Fonctionne sur
|
|
82
|
+
les 3 plateformes (Claude Code, Codex, Copilot).
|
|
83
|
+
|
|
84
|
+
Protocole : lock du scope -> build complet -> self-verify >= 3 passes -> complete
|
|
85
|
+
(jeton d'audit). Le commit est bloque tant que la verification n'est pas acquise.
|
|
86
|
+
|
|
87
|
+
- Source de verite : `_byan/_config/strict-mode.yaml` (regenerer via `byan-sync-rules`)
|
|
88
|
+
- Outils MCP : `byan_strict_lock_scope`, `byan_strict_self_verify`, `byan_strict_complete`, `byan_strict_status`, `byan_strict_abort`, `byan_strict_suggest`
|
|
89
|
+
- Activation : `byan_fd_start strict:true`, skill `byan-strict`, ou mots-cles (prod, client, livrable...)
|
|
90
|
+
- Filet final : `.githooks/pre-commit` bloque le commit si une session strict est engagee mais non completee
|
|
91
|
+
- Persistance : sessions poussees vers l'API byan_web (autorite ; local = miroir/fallback offline)
|
|
92
|
+
|
|
93
|
+
Detail complet : voir @.claude/rules/strict-mode.md
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_generated_by": "byan-sync-rules",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"min_passes": 3,
|
|
5
|
+
"last_verdict_must_be": "ok",
|
|
6
|
+
"min_score": 95,
|
|
7
|
+
"auto_keywords": [
|
|
8
|
+
"prod",
|
|
9
|
+
"production",
|
|
10
|
+
"client",
|
|
11
|
+
"contrat",
|
|
12
|
+
"template officiel",
|
|
13
|
+
"livrable",
|
|
14
|
+
"deliverable",
|
|
15
|
+
"mise en production",
|
|
16
|
+
"release"
|
|
17
|
+
],
|
|
18
|
+
"completion_claim_markers": [
|
|
19
|
+
"done",
|
|
20
|
+
"finished",
|
|
21
|
+
"complete",
|
|
22
|
+
"delivered",
|
|
23
|
+
"ready",
|
|
24
|
+
"shipped",
|
|
25
|
+
"terminé",
|
|
26
|
+
"fini",
|
|
27
|
+
"livré",
|
|
28
|
+
"prêt",
|
|
29
|
+
"c'est bon",
|
|
30
|
+
"voilà"
|
|
31
|
+
],
|
|
32
|
+
"scope_guard": {
|
|
33
|
+
"enforce_paths": true,
|
|
34
|
+
"exempt_globs": [
|
|
35
|
+
".byan-strict/",
|
|
36
|
+
"_byan-output/",
|
|
37
|
+
".git/"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"freshness_window_seconds": 600,
|
|
41
|
+
"banners": {
|
|
42
|
+
"context": "[STRICT MODE ACTIVE]\nYou are under BYAN Strict Mode. Before building:\n 1. Lock the scope (byan_strict_lock_scope) with testable acceptance criteria.\nWhile building:\n 2. Do not downgrade the scope. Surface any gap, do not cut silently.\nBefore delivering:\n 3. Run >= 3 self-verify passes (byan_strict_self_verify), re-reading the\n original request each time. The last pass must report verdict \"ok\".\n 4. Call byan_strict_complete to earn the audit token.\nHard claims (security/performance/compliance) require LEVEL-1 sourcing (95%).\nA commit without a fresh, matching audit token is blocked by the pre-commit gate.\n",
|
|
43
|
+
"stop_block": "Strict mode: the turn cannot end. The locked scope has not passed three self-verify passes with a final \"ok\" verdict. Run byan_strict_self_verify until the scope is satisfied, then byan_strict_complete.",
|
|
44
|
+
"scope_deny": "Strict mode: this write targets a path outside the locked scope. Either it belongs to the scope (re-lock with the corrected paths) or it does not (do not write it)."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared runtime helpers for the BYAN Strict Mode hooks.
|
|
2
|
+
//
|
|
3
|
+
// Reads two files :
|
|
4
|
+
// - .claude/hooks/lib/strict-config.json : generated from strict-mode.yaml
|
|
5
|
+
// by byan-sync-rules (static config : thresholds, keywords, banners).
|
|
6
|
+
// - .byan-strict/state.json : the live session state written by the
|
|
7
|
+
// byan_strict_* MCP tools (lib/strict-mode.js).
|
|
8
|
+
//
|
|
9
|
+
// Hooks only READ here. The authoritative writes live in the MCP tools.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function projectRoot() {
|
|
15
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readJson(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(filePath)) return null;
|
|
21
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadConfig() {
|
|
28
|
+
const p = path.join(projectRoot(), '.claude', 'hooks', 'lib', 'strict-config.json');
|
|
29
|
+
return readJson(p);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadState() {
|
|
33
|
+
const p = path.join(projectRoot(), '.byan-strict', 'state.json');
|
|
34
|
+
return readJson(p);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A strict session is "engaged" when it is active, has a locked scope, and
|
|
38
|
+
// has not been completed yet. This is the window where enforcement applies.
|
|
39
|
+
function isEngaged(state) {
|
|
40
|
+
return Boolean(state && state.active && state.scope_lock && !state.completed);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function passCount(state) {
|
|
44
|
+
return state && Array.isArray(state.self_verify_passes)
|
|
45
|
+
? state.self_verify_passes.length
|
|
46
|
+
: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lastVerdict(state) {
|
|
50
|
+
const passes = state && state.self_verify_passes;
|
|
51
|
+
if (!Array.isArray(passes) || passes.length === 0) return null;
|
|
52
|
+
return passes[passes.length - 1].verdict;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStdin() {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
if (process.stdin.isTTY) return resolve('');
|
|
58
|
+
let data = '';
|
|
59
|
+
process.stdin.on('data', (c) => (data += c));
|
|
60
|
+
process.stdin.on('end', () => resolve(data));
|
|
61
|
+
process.stdin.on('error', () => resolve(data));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseJson(raw) {
|
|
66
|
+
try {
|
|
67
|
+
return raw ? JSON.parse(raw) : {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
projectRoot,
|
|
75
|
+
loadConfig,
|
|
76
|
+
loadState,
|
|
77
|
+
isEngaged,
|
|
78
|
+
passCount,
|
|
79
|
+
lastVerdict,
|
|
80
|
+
readStdin,
|
|
81
|
+
parseJson,
|
|
82
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit hook — BYAN Strict Mode context injector.
|
|
4
|
+
*
|
|
5
|
+
* Two behaviors :
|
|
6
|
+
* - When a strict session is engaged, inject the strict banner plus a live
|
|
7
|
+
* status line (passes done / required, locked scope hash) so the agent
|
|
8
|
+
* stays anchored to the contract on every turn.
|
|
9
|
+
* - When no session is engaged but the user's prompt contains an activation
|
|
10
|
+
* keyword (prod, production, client, contrat, ...), inject a suggestion to
|
|
11
|
+
* lock strict mode before building. It suggests, it does not auto-lock.
|
|
12
|
+
*
|
|
13
|
+
* Emits empty context on any error.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
loadConfig,
|
|
18
|
+
loadState,
|
|
19
|
+
isEngaged,
|
|
20
|
+
passCount,
|
|
21
|
+
lastVerdict,
|
|
22
|
+
readStdin,
|
|
23
|
+
parseJson,
|
|
24
|
+
} = require('./lib/strict-runtime');
|
|
25
|
+
|
|
26
|
+
function findKeyword(prompt, keywords) {
|
|
27
|
+
if (!prompt || !Array.isArray(keywords)) return null;
|
|
28
|
+
const lower = prompt.toLowerCase();
|
|
29
|
+
for (const k of keywords) {
|
|
30
|
+
const kw = String(k).toLowerCase();
|
|
31
|
+
if (/^[a-z]+$/.test(kw)) {
|
|
32
|
+
if (new RegExp(`\\b${kw}\\b`).test(lower)) return k;
|
|
33
|
+
} else if (lower.includes(kw)) {
|
|
34
|
+
return k;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pure : returns the additionalContext string (possibly empty).
|
|
41
|
+
function buildContext({ state, config, prompt }) {
|
|
42
|
+
if (isEngaged(state)) {
|
|
43
|
+
const minPasses = (config && config.min_passes) || 3;
|
|
44
|
+
const banner = (config && config.banners && config.banners.context) || '[STRICT MODE ACTIVE]';
|
|
45
|
+
const done = passCount(state);
|
|
46
|
+
const hash = state.scope_lock ? state.scope_lock.scope_hash : 'unknown';
|
|
47
|
+
return (
|
|
48
|
+
`${banner}\n` +
|
|
49
|
+
`Locked scope: ${hash} | self-verify ${done}/${minPasses} | last verdict ${lastVerdict(state) || 'none'}.\n` +
|
|
50
|
+
`Stay inside the locked scope. Do not declare done before byan_strict_complete returns an audit token.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const keyword = findKeyword(prompt, config && config.auto_keywords);
|
|
55
|
+
if (keyword) {
|
|
56
|
+
return (
|
|
57
|
+
`[STRICT MODE SUGGESTED]\n` +
|
|
58
|
+
`The request mentions "${keyword}", which signals a production-grade deliverable. ` +
|
|
59
|
+
`Before building, consider locking strict mode with byan_strict_lock_scope ` +
|
|
60
|
+
`(verbatim scope + testable acceptance criteria). Strict mode enforces ` +
|
|
61
|
+
`>= ${(config && config.min_passes) || 3} self-verify passes and a 95% confidence floor on hard claims. ` +
|
|
62
|
+
`Confirm with the user, then lock.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (require.main === module) {
|
|
70
|
+
(async () => {
|
|
71
|
+
const state = loadState();
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
const payload = parseJson(await readStdin());
|
|
74
|
+
const prompt = payload.prompt || payload.user_prompt || payload.userPrompt || '';
|
|
75
|
+
|
|
76
|
+
const additionalContext = buildContext({ state, config, prompt });
|
|
77
|
+
process.stdout.write(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { buildContext, findKeyword };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse hook — BYAN Strict Mode scope guard.
|
|
4
|
+
*
|
|
5
|
+
* When a strict session is engaged and the locked scope declares allowed
|
|
6
|
+
* paths, deny Write/Edit calls that target a file outside those paths. This
|
|
7
|
+
* keeps the agent inside the contract it locked : it cannot silently spread
|
|
8
|
+
* changes across the repo under the cover of the locked task.
|
|
9
|
+
*
|
|
10
|
+
* Exempt paths (the strict bookkeeping, build output, git) are always
|
|
11
|
+
* allowed. If enforce_paths is off or no allowed paths were declared, every
|
|
12
|
+
* write is allowed.
|
|
13
|
+
*
|
|
14
|
+
* Non-blocking on parse error.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { loadConfig, loadState, isEngaged, projectRoot, readStdin, parseJson } =
|
|
19
|
+
require('./lib/strict-runtime');
|
|
20
|
+
|
|
21
|
+
function toRelative(filePath, root) {
|
|
22
|
+
if (!filePath) return '';
|
|
23
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
24
|
+
const rel = path.relative(root, abs);
|
|
25
|
+
return rel.split(path.sep).join('/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchesPrefix(rel, prefix) {
|
|
29
|
+
const p = String(prefix).replace(/\/+$/, '');
|
|
30
|
+
return rel === p || rel.startsWith(p + '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pure decision : returns { deny, reason }.
|
|
34
|
+
function decideScope({ state, config, toolName, filePath }) {
|
|
35
|
+
if (!['Write', 'Edit'].includes(toolName)) return { deny: false };
|
|
36
|
+
if (!isEngaged(state)) return { deny: false };
|
|
37
|
+
|
|
38
|
+
const guard = (config && config.scope_guard) || {};
|
|
39
|
+
if (!guard.enforce_paths) return { deny: false };
|
|
40
|
+
|
|
41
|
+
const allowed = (state.scope_lock && state.scope_lock.allowed_paths) || [];
|
|
42
|
+
if (!Array.isArray(allowed) || allowed.length === 0) return { deny: false };
|
|
43
|
+
|
|
44
|
+
const root = projectRoot();
|
|
45
|
+
const rel = toRelative(filePath, root);
|
|
46
|
+
if (!rel) return { deny: false };
|
|
47
|
+
|
|
48
|
+
const exempt = guard.exempt_globs || [];
|
|
49
|
+
if (exempt.some((g) => matchesPrefix(rel, g))) return { deny: false };
|
|
50
|
+
|
|
51
|
+
if (allowed.some((a) => matchesPrefix(rel, a))) return { deny: false };
|
|
52
|
+
|
|
53
|
+
const base =
|
|
54
|
+
(config && config.banners && config.banners.scope_deny) ||
|
|
55
|
+
'Strict mode: this write targets a path outside the locked scope.';
|
|
56
|
+
const reason =
|
|
57
|
+
`${base}\n` +
|
|
58
|
+
`Target: ${rel}\n` +
|
|
59
|
+
`Locked paths: ${allowed.join(', ')}\n` +
|
|
60
|
+
`Either this file belongs to the scope (re-lock with byan_strict_lock_scope ` +
|
|
61
|
+
`including the corrected paths) or it does not (do not write it).`;
|
|
62
|
+
|
|
63
|
+
return { deny: true, reason };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function allow() {
|
|
67
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (require.main === module) {
|
|
71
|
+
(async () => {
|
|
72
|
+
const state = loadState();
|
|
73
|
+
if (!isEngaged(state)) {
|
|
74
|
+
process.stdout.write(JSON.stringify(allow()));
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const payload = parseJson(await readStdin());
|
|
79
|
+
const toolName = payload.tool_name || payload.toolName || '';
|
|
80
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
81
|
+
const filePath = input.file_path || '';
|
|
82
|
+
|
|
83
|
+
const decision = decideScope({ state, config, toolName, filePath });
|
|
84
|
+
if (!decision.deny) {
|
|
85
|
+
process.stdout.write(JSON.stringify(allow()));
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
process.stdout.write(
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
hookSpecificOutput: {
|
|
91
|
+
hookEventName: 'PreToolUse',
|
|
92
|
+
permissionDecision: 'deny',
|
|
93
|
+
permissionDecisionReason: decision.reason,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
process.exit(0);
|
|
98
|
+
})();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { decideScope, toRelative, matchesPrefix };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook — BYAN Strict Mode end-of-turn guard.
|
|
4
|
+
*
|
|
5
|
+
* When a strict session is engaged (active + scope locked + not completed),
|
|
6
|
+
* block the turn from ending IF the assistant's last message claims the work
|
|
7
|
+
* is done. Completion must be earned through byan_strict_complete (3 passes,
|
|
8
|
+
* last verdict "ok"), which flips state.completed and disengages this guard.
|
|
9
|
+
*
|
|
10
|
+
* A mid-task yield (asking the user a question, reporting progress without a
|
|
11
|
+
* completion claim) is allowed — the guard only fires on a premature "done".
|
|
12
|
+
*
|
|
13
|
+
* Non-blocking on any IO/parse error : the hook never traps a turn when it
|
|
14
|
+
* cannot read the state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { loadConfig, loadState, isEngaged, passCount, lastVerdict, readStdin, parseJson } =
|
|
18
|
+
require('./lib/strict-runtime');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MARKERS = ['done', 'finished', 'complete', 'delivered', 'ready'];
|
|
21
|
+
|
|
22
|
+
function claimsCompletion(text, markers) {
|
|
23
|
+
if (!text) return false;
|
|
24
|
+
const lower = text.toLowerCase();
|
|
25
|
+
return (markers || DEFAULT_MARKERS).some((m) => {
|
|
26
|
+
const marker = String(m).toLowerCase();
|
|
27
|
+
if (/^[a-z]+$/.test(marker)) {
|
|
28
|
+
return new RegExp(`\\b${marker}\\b`).test(lower);
|
|
29
|
+
}
|
|
30
|
+
return lower.includes(marker);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractLastAssistantText(payload) {
|
|
35
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
36
|
+
const tx = payload.transcript || payload.messages || [];
|
|
37
|
+
if (!Array.isArray(tx)) return '';
|
|
38
|
+
for (let i = tx.length - 1; i >= 0; i--) {
|
|
39
|
+
const m = tx[i];
|
|
40
|
+
if (m && m.role === 'assistant') {
|
|
41
|
+
if (typeof m.content === 'string') return m.content;
|
|
42
|
+
if (Array.isArray(m.content)) {
|
|
43
|
+
return m.content.map((c) => (c && c.text ? c.text : '')).join(' ');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure decision : returns { block, reason }.
|
|
51
|
+
function decideStop({ state, config, lastAssistantText }) {
|
|
52
|
+
if (!isEngaged(state)) return { block: false };
|
|
53
|
+
|
|
54
|
+
const markers = config && config.completion_claim_markers;
|
|
55
|
+
if (!claimsCompletion(lastAssistantText, markers)) return { block: false };
|
|
56
|
+
|
|
57
|
+
const minPasses = (config && config.min_passes) || 3;
|
|
58
|
+
const done = passCount(state);
|
|
59
|
+
const verdict = lastVerdict(state);
|
|
60
|
+
|
|
61
|
+
// Defensive : if somehow 3 ok passes are recorded but complete() was not
|
|
62
|
+
// called, still block and tell the agent to call complete.
|
|
63
|
+
const base =
|
|
64
|
+
(config && config.banners && config.banners.stop_block) ||
|
|
65
|
+
'Strict mode: the turn cannot end. The locked scope has not been completed.';
|
|
66
|
+
|
|
67
|
+
const reason =
|
|
68
|
+
`${base}\n` +
|
|
69
|
+
`Progress: ${done}/${minPasses} self-verify passes, last verdict=${verdict || 'none'}.\n` +
|
|
70
|
+
`You claimed completion but byan_strict_complete has not produced an audit token. ` +
|
|
71
|
+
`Run byan_strict_self_verify until the scope is satisfied (last pass verdict "ok"), ` +
|
|
72
|
+
`then call byan_strict_complete. If the scope changed, re-lock it.`;
|
|
73
|
+
|
|
74
|
+
return { block: true, reason };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (require.main === module) {
|
|
78
|
+
(async () => {
|
|
79
|
+
const state = loadState();
|
|
80
|
+
if (!isEngaged(state)) {
|
|
81
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
const payload = parseJson(await readStdin());
|
|
86
|
+
const lastAssistantText = extractLastAssistantText(payload);
|
|
87
|
+
|
|
88
|
+
const decision = decideStop({ state, config, lastAssistantText });
|
|
89
|
+
if (!decision.block) {
|
|
90
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(
|
|
94
|
+
JSON.stringify({ decision: 'block', reason: decision.reason, systemMessage: decision.reason })
|
|
95
|
+
);
|
|
96
|
+
process.exit(2);
|
|
97
|
+
})();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { decideStop, claimsCompletion, extractLastAssistantText };
|