chati-dev 4.2.0 → 4.2.2
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/framework/config.yaml +3 -3
- package/framework/constitution.md +3 -1
- package/framework/context/governance.md +24 -1
- package/framework/context/quality.md +14 -1
- package/framework/context/root.md +4 -3
- package/framework/hooks/constitution-guard.js +69 -33
- package/framework/hooks/license-guard.js +92 -188
- package/framework/hooks/mode-governance.js +55 -14
- package/framework/hooks/model-governance.js +18 -8
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/prism-engine.js +22 -8
- package/framework/hooks/read-protection.js +37 -9
- package/framework/hooks/session-digest.js +45 -20
- package/framework/hooks/style-guard.js +30 -10
- package/framework/hooks/team-quality-gate.js +39 -13
- package/framework/hooks/undercover-guard.js +30 -11
- package/framework/orchestrator/chati.md +31 -28
- package/package.json +1 -1
- package/scripts/validate-package.js +256 -8
- package/src/config/claude-settings-generator.js +206 -0
- package/src/config/gemini-hooks-generator.js +58 -0
- package/src/installer/core.js +223 -129
- package/src/installer/templates.js +38 -0
- package/src/orchestrator/cli.js +41 -11
|
@@ -41,12 +41,25 @@ const STATE_TO_GOVERNANCE_MODE = {
|
|
|
41
41
|
|
|
42
42
|
function getCurrentMode(projectDir) {
|
|
43
43
|
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
44
|
-
|
|
44
|
+
// No session = no enforcement yet (orchestrator will set mode on first run).
|
|
45
|
+
// Defaulting to planning here would deadlock fresh installs (planning blocks
|
|
46
|
+
// session.yaml writes, so the orchestrator could never bootstrap).
|
|
47
|
+
if (!existsSync(sessionPath)) return 'build';
|
|
45
48
|
|
|
46
49
|
const raw = readFileSync(sessionPath, 'utf-8');
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
+
// Prefer explicit mode field; fall back to project.state mapping.
|
|
51
|
+
const modeMatch = raw.match(/^\s*mode:\s*(.+)$/m);
|
|
52
|
+
if (modeMatch) {
|
|
53
|
+
const mode = modeMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
54
|
+
if (MODE_SCOPES[mode]) return mode;
|
|
55
|
+
}
|
|
56
|
+
const stateMatch = raw.match(/^\s+state:\s*(.+)$/m);
|
|
57
|
+
if (stateMatch) {
|
|
58
|
+
const state = stateMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
59
|
+
return STATE_TO_GOVERNANCE_MODE[state] || 'build';
|
|
60
|
+
}
|
|
61
|
+
// Session exists but no mode/state field — be permissive.
|
|
62
|
+
return 'build';
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
function isPathAllowed(filePath, projectDir, mode) {
|
|
@@ -74,31 +87,59 @@ async function main() {
|
|
|
74
87
|
const filePath = toolInput.file_path || toolInput.path || '';
|
|
75
88
|
|
|
76
89
|
if (!filePath) {
|
|
77
|
-
process.stdout.write(JSON.stringify(
|
|
90
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
78
91
|
return;
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
const mode = getCurrentMode(projectDir);
|
|
82
95
|
|
|
83
96
|
if (isPathAllowed(filePath, projectDir, mode)) {
|
|
84
|
-
process.stdout.write(JSON.stringify(
|
|
97
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
85
98
|
} else {
|
|
86
99
|
const scope = MODE_SCOPES[mode];
|
|
87
|
-
process.stdout.write(JSON.stringify(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}));
|
|
100
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
101
|
+
`[Article XI] ${scope.description}. Cannot write to "${filePath}" in ${mode} mode.`
|
|
102
|
+
)));
|
|
91
103
|
}
|
|
92
104
|
} catch (err) {
|
|
93
105
|
process.stderr.write(`[chati-hook-error] mode-governance: ${err?.message || 'unknown'}\n`);
|
|
94
|
-
|
|
106
|
+
// Hook errors are fail-OPEN: a broken hook should not block the user.
|
|
107
|
+
// Security-critical denials are handled by explicit logic above.
|
|
108
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
95
109
|
}
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
function allowOutput() {
|
|
113
|
+
return {
|
|
114
|
+
hookSpecificOutput: {
|
|
115
|
+
hookEventName: 'PreToolUse',
|
|
116
|
+
permissionDecision: 'allow',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function denyOutput(reason) {
|
|
122
|
+
return {
|
|
123
|
+
hookSpecificOutput: {
|
|
124
|
+
hookEventName: 'PreToolUse',
|
|
125
|
+
permissionDecision: 'deny',
|
|
126
|
+
permissionDecisionReason: reason,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
export { getCurrentMode, isPathAllowed, MODE_SCOPES, STATE_TO_GOVERNANCE_MODE };
|
|
99
132
|
|
|
100
|
-
// Only run main when executed directly (not imported by tests)
|
|
133
|
+
// Only run main when executed directly (not imported by tests).
|
|
134
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
135
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
136
|
+
// silently and main() never runs.
|
|
101
137
|
import { fileURLToPath } from 'url';
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
import { realpathSync } from 'fs';
|
|
139
|
+
if (process.argv[1]) {
|
|
140
|
+
try {
|
|
141
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
142
|
+
main();
|
|
143
|
+
}
|
|
144
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
104
145
|
}
|
|
@@ -80,24 +80,34 @@ async function main() {
|
|
|
80
80
|
const assignment = AGENT_MODELS[agent];
|
|
81
81
|
const expected = assignment.model || assignment;
|
|
82
82
|
const provider = assignment.provider || 'claude';
|
|
83
|
-
// Advisory
|
|
83
|
+
// Advisory context injection via canonical UserPromptSubmit schema.
|
|
84
84
|
process.stdout.write(JSON.stringify({
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
hookSpecificOutput: {
|
|
86
|
+
hookEventName: 'UserPromptSubmit',
|
|
87
|
+
additionalContext: `[Article XVI] Agent "${agent}" assigned model: ${expected} (provider: ${provider})`,
|
|
88
|
+
},
|
|
87
89
|
}));
|
|
88
90
|
} else {
|
|
89
|
-
process.stdout.write(
|
|
91
|
+
process.stdout.write('{}');
|
|
90
92
|
}
|
|
91
93
|
} catch (err) {
|
|
92
94
|
process.stderr.write(`[chati] model-governance: ${err.message}\n`);
|
|
93
|
-
process.stdout.write(
|
|
95
|
+
process.stdout.write('{}');
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
|
|
98
100
|
|
|
99
|
-
// Only run main when executed directly (not imported by tests)
|
|
101
|
+
// Only run main when executed directly (not imported by tests).
|
|
102
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
103
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
104
|
+
// silently and main() never runs.
|
|
100
105
|
import { fileURLToPath } from 'url';
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
import { realpathSync } from 'fs';
|
|
107
|
+
if (process.argv[1]) {
|
|
108
|
+
try {
|
|
109
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
110
|
+
main();
|
|
111
|
+
}
|
|
112
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
103
113
|
}
|
|
@@ -65,8 +65,8 @@ async function main() {
|
|
|
65
65
|
const session = readSessionState(projectDir);
|
|
66
66
|
|
|
67
67
|
if (!session) {
|
|
68
|
-
// No active session — don't inject anything
|
|
69
|
-
process.stdout.write(
|
|
68
|
+
// No active session — don't inject anything (empty output = allow)
|
|
69
|
+
process.stdout.write('{}');
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -163,13 +163,19 @@ async function main() {
|
|
|
163
163
|
'</chati-context>',
|
|
164
164
|
].filter(Boolean).join('\n');
|
|
165
165
|
|
|
166
|
+
// UserPromptSubmit context injection schema:
|
|
167
|
+
// hookSpecificOutput.additionalContext is the canonical field for
|
|
168
|
+
// injecting text into Claude's view of the current prompt.
|
|
166
169
|
process.stdout.write(JSON.stringify({
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
hookSpecificOutput: {
|
|
171
|
+
hookEventName: 'UserPromptSubmit',
|
|
172
|
+
additionalContext: contextBlock,
|
|
173
|
+
},
|
|
169
174
|
}));
|
|
170
175
|
} catch (err) {
|
|
171
176
|
process.stderr.write(`[chati] prism-engine: ${err.message}\n`);
|
|
172
|
-
|
|
177
|
+
// Silent allow on error — empty object = no injection, no block.
|
|
178
|
+
process.stdout.write('{}');
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
|
@@ -201,8 +207,16 @@ function detectFrustration(prompt) {
|
|
|
201
207
|
|
|
202
208
|
export { readSessionState, detectFrustration };
|
|
203
209
|
|
|
204
|
-
// Only run main when executed directly (not imported by tests)
|
|
210
|
+
// Only run main when executed directly (not imported by tests).
|
|
211
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
212
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
213
|
+
// silently and main() never runs.
|
|
205
214
|
import { fileURLToPath } from 'url';
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
import { realpathSync } from 'fs';
|
|
216
|
+
if (process.argv[1]) {
|
|
217
|
+
try {
|
|
218
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
219
|
+
main();
|
|
220
|
+
}
|
|
221
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
208
222
|
}
|
|
@@ -93,24 +93,52 @@ async function main() {
|
|
|
93
93
|
const result = isSensitivePath(filePath, cwd);
|
|
94
94
|
|
|
95
95
|
if (result.sensitive) {
|
|
96
|
-
process.stdout.write(JSON.stringify(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}));
|
|
96
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
97
|
+
`[Article IV] ${result.reason}. Use environment variables or secure vaults instead of reading sensitive files directly.`
|
|
98
|
+
)));
|
|
100
99
|
return;
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
process.stdout.write(JSON.stringify(
|
|
102
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
104
103
|
} catch (err) {
|
|
105
104
|
process.stderr.write(`[chati-hook-error] read-protection: ${err?.message || 'unknown'}\n`);
|
|
106
|
-
|
|
105
|
+
// Fail-open: a broken hook should not prevent reads. Real protection
|
|
106
|
+
// patterns are explicit above and run before this catch.
|
|
107
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
function allowOutput() {
|
|
112
|
+
return {
|
|
113
|
+
hookSpecificOutput: {
|
|
114
|
+
hookEventName: 'PreToolUse',
|
|
115
|
+
permissionDecision: 'allow',
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function denyOutput(reason) {
|
|
121
|
+
return {
|
|
122
|
+
hookSpecificOutput: {
|
|
123
|
+
hookEventName: 'PreToolUse',
|
|
124
|
+
permissionDecision: 'deny',
|
|
125
|
+
permissionDecisionReason: reason,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
110
130
|
export { isSensitivePath, SENSITIVE_PATTERNS, ALLOWED_EXCEPTIONS };
|
|
111
131
|
|
|
112
|
-
// Only run main when executed directly (not imported by tests)
|
|
132
|
+
// Only run main when executed directly (not imported by tests).
|
|
133
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
134
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
135
|
+
// silently and main() never runs.
|
|
113
136
|
import { fileURLToPath } from 'url';
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
import { realpathSync } from 'fs';
|
|
138
|
+
if (process.argv[1]) {
|
|
139
|
+
try {
|
|
140
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
141
|
+
main();
|
|
142
|
+
}
|
|
143
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
116
144
|
}
|
|
@@ -115,7 +115,9 @@ async function main() {
|
|
|
115
115
|
process.stderr.write(`[chati] session-digest daily-append: ${err.message}\n`);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// Auto memory consolidation: trigger when memories accumulate
|
|
118
|
+
// Auto memory consolidation: trigger when memories accumulate.
|
|
119
|
+
// CRITICAL: must be fully async (detached spawn) — PreCompact runs on the
|
|
120
|
+
// hot path and execSync would add up to 10s of latency to every compact.
|
|
119
121
|
try {
|
|
120
122
|
const memBase = join(projectDir, '.chati', 'memories');
|
|
121
123
|
if (existsSync(memBase)) {
|
|
@@ -132,16 +134,19 @@ async function main() {
|
|
|
132
134
|
// Consolidate when > 100 entries (50% of 200 cap)
|
|
133
135
|
if (totalEntries > 100) {
|
|
134
136
|
process.stderr.write(`[chati] auto-dream: ${totalEntries} entries, triggering consolidation\n`);
|
|
135
|
-
// Fire-and-forget
|
|
136
|
-
|
|
137
|
-
const { execSync } = await import('child_process');
|
|
137
|
+
// Fire-and-forget detached spawn — returns immediately, child outlives parent.
|
|
138
|
+
const { spawn } = await import('child_process');
|
|
138
139
|
try {
|
|
139
|
-
|
|
140
|
+
const child = spawn('node', [
|
|
141
|
+
'-e',
|
|
142
|
+
`import('./packages/chati-dev/src/memory/dream.js').then(m => m.runDreamConsolidation(${JSON.stringify(projectDir)}))`,
|
|
143
|
+
], {
|
|
140
144
|
cwd: projectDir,
|
|
141
|
-
|
|
145
|
+
detached: true,
|
|
142
146
|
stdio: 'ignore',
|
|
143
147
|
});
|
|
144
|
-
|
|
148
|
+
child.unref();
|
|
149
|
+
} catch { /* expected: spawn may fail in restricted env */ }
|
|
145
150
|
}
|
|
146
151
|
}
|
|
147
152
|
} catch (err) {
|
|
@@ -149,8 +154,9 @@ async function main() {
|
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
// Session telemetry ping (moved from license-guard
|
|
153
|
-
// 5-minute throttle with phase/agent change detection
|
|
157
|
+
// Session telemetry ping (owned by session-digest, was moved here from license-guard).
|
|
158
|
+
// 5-minute throttle with phase/agent change detection.
|
|
159
|
+
// Pings.yaml format: '/path/to/project': '<ISO8601>|<phase>|<agent>'
|
|
154
160
|
try {
|
|
155
161
|
const homedir = process.env.HOME || '';
|
|
156
162
|
const pingsPath = join(homedir, '.chati-dev', 'pings.yaml');
|
|
@@ -163,15 +169,17 @@ async function main() {
|
|
|
163
169
|
|
|
164
170
|
if (licenseKey && digest) {
|
|
165
171
|
let shouldPing = false;
|
|
166
|
-
const
|
|
172
|
+
const nowIso = new Date().toISOString();
|
|
167
173
|
const currentState = `${digest.mode}|${digest.currentAgent}`;
|
|
168
174
|
|
|
169
175
|
if (existsSync(pingsPath)) {
|
|
170
176
|
const pingsRaw = readFileSync(pingsPath, 'utf-8');
|
|
171
|
-
const
|
|
177
|
+
const escaped = projectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
178
|
+
const entryMatch = pingsRaw.match(new RegExp(`^${escaped}:\\s*'([^']+)'`, 'm'));
|
|
172
179
|
if (entryMatch) {
|
|
173
180
|
const [ts, phase, agent] = entryMatch[1].split('|');
|
|
174
|
-
const
|
|
181
|
+
const lastTime = new Date(ts).getTime();
|
|
182
|
+
const elapsed = isNaN(lastTime) ? Infinity : Date.now() - lastTime;
|
|
175
183
|
const stateChanged = `${phase}|${agent}` !== currentState;
|
|
176
184
|
shouldPing = elapsed > 300000 || stateChanged; // 5 min or state change
|
|
177
185
|
} else {
|
|
@@ -182,16 +190,24 @@ async function main() {
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
if (shouldPing) {
|
|
185
|
-
// Update ping
|
|
193
|
+
// Update ping entry — preserve other projects in the file.
|
|
186
194
|
mkdirSync(join(homedir, '.chati-dev'), { recursive: true });
|
|
187
|
-
|
|
195
|
+
const newEntry = `${projectDir}: '${nowIso}|${currentState}'\n`;
|
|
196
|
+
let content = existsSync(pingsPath) ? readFileSync(pingsPath, 'utf-8') : '';
|
|
197
|
+
const escaped = projectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
198
|
+
if (new RegExp(`^${escaped}:`, 'm').test(content)) {
|
|
199
|
+
content = content.replace(new RegExp(`^${escaped}:.*$`, 'm'), newEntry.trim());
|
|
200
|
+
} else {
|
|
201
|
+
content += newEntry;
|
|
202
|
+
}
|
|
203
|
+
writeFileSync(pingsPath, content, 'utf-8');
|
|
188
204
|
|
|
189
205
|
// Fire-and-forget telemetry (3s timeout, never blocks)
|
|
190
206
|
try {
|
|
191
207
|
const body = JSON.stringify({
|
|
192
208
|
license_key: licenseKey,
|
|
193
209
|
project_name: digest.currentAgent || 'unknown',
|
|
194
|
-
events: [{ type: 'session_active', timestamp:
|
|
210
|
+
events: [{ type: 'session_active', timestamp: nowIso, properties: { pipeline_phase: digest.mode, current_agent: digest.currentAgent } }],
|
|
195
211
|
});
|
|
196
212
|
fetch('https://chati.dev/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(3000) }).catch(() => {});
|
|
197
213
|
} catch { /* expected: network may be unavailable */ }
|
|
@@ -202,15 +218,24 @@ async function main() {
|
|
|
202
218
|
process.stderr.write(`[chati] session-digest telemetry-ping: ${err.message}\n`);
|
|
203
219
|
}
|
|
204
220
|
|
|
205
|
-
|
|
221
|
+
// PreCompact is observational only — empty output is the canonical "no-op".
|
|
222
|
+
process.stdout.write('{}');
|
|
206
223
|
} catch (err) {
|
|
207
224
|
process.stderr.write(`[chati] session-digest: ${err.message}\n`);
|
|
208
|
-
process.stdout.write(
|
|
225
|
+
process.stdout.write('{}');
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
228
|
|
|
212
|
-
// Only run main when executed directly (not imported by tests)
|
|
229
|
+
// Only run main when executed directly (not imported by tests).
|
|
230
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
231
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
232
|
+
// silently and main() never runs.
|
|
213
233
|
import { fileURLToPath } from 'url';
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
import { realpathSync } from 'fs';
|
|
235
|
+
if (process.argv[1]) {
|
|
236
|
+
try {
|
|
237
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
238
|
+
main();
|
|
239
|
+
}
|
|
240
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
216
241
|
}
|
|
@@ -97,7 +97,7 @@ async function main() {
|
|
|
97
97
|
// Skip exempt paths
|
|
98
98
|
const filePath = toolInput.file_path || '';
|
|
99
99
|
if (isExemptPath(filePath, projectDir)) {
|
|
100
|
-
process.stdout.write(JSON.stringify(
|
|
100
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -112,24 +112,44 @@ async function main() {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
if (result.violations.length > 0) {
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
// Advisory: surface as reason but allow. Style is a soft preference,
|
|
116
|
+
// not a security boundary — hard-blocking blocks legitimate markdown.
|
|
117
|
+
process.stdout.write(JSON.stringify(allowOutput(
|
|
118
|
+
`[Style Guard] ${result.violations.join(' ')} Consider rewriting without em-dashes and emojis (Article V).`
|
|
119
|
+
)));
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
process.stdout.write(JSON.stringify(
|
|
123
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
124
124
|
} catch (err) {
|
|
125
125
|
process.stderr.write(`[chati] style-guard: ${err.message}\n`);
|
|
126
|
-
process.stdout.write(JSON.stringify(
|
|
126
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
function allowOutput(reason) {
|
|
131
|
+
const out = {
|
|
132
|
+
hookSpecificOutput: {
|
|
133
|
+
hookEventName: 'PreToolUse',
|
|
134
|
+
permissionDecision: 'allow',
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
export { checkStyle, checkBashStyle, isExemptPath, EM_DASH_PATTERN, EMOJI_PATTERN };
|
|
131
142
|
|
|
143
|
+
// Only run main when executed directly (not imported by tests).
|
|
144
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
145
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
146
|
+
// silently and main() never runs.
|
|
132
147
|
import { fileURLToPath } from 'url';
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
import { realpathSync } from 'fs';
|
|
149
|
+
if (process.argv[1]) {
|
|
150
|
+
try {
|
|
151
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
152
|
+
main();
|
|
153
|
+
}
|
|
154
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
135
155
|
}
|
|
@@ -79,7 +79,7 @@ async function main() {
|
|
|
79
79
|
// Quick exit: no team active → allow everything
|
|
80
80
|
const teamId = process.env.CHATI_TEAM_ID;
|
|
81
81
|
if (!teamId) {
|
|
82
|
-
process.stdout.write(JSON.stringify(
|
|
82
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -87,14 +87,14 @@ async function main() {
|
|
|
87
87
|
const currentAgent = process.env.CHATI_TEAM_MEMBER || detectAgentFromSession(projectDir);
|
|
88
88
|
if (!currentAgent) {
|
|
89
89
|
// Cannot determine agent → allow (fail-open for safety)
|
|
90
|
-
process.stdout.write(JSON.stringify(
|
|
90
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Get the file path being written
|
|
95
95
|
const filePath = event.tool_input?.file_path || '';
|
|
96
96
|
if (!filePath) {
|
|
97
|
-
process.stdout.write(JSON.stringify(
|
|
97
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
98
98
|
return;
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -103,7 +103,7 @@ async function main() {
|
|
|
103
103
|
|
|
104
104
|
// Check shared paths first — always allowed
|
|
105
105
|
if (SHARED_PATHS.some(sp => relativePath.startsWith(sp))) {
|
|
106
|
-
process.stdout.write(JSON.stringify(
|
|
106
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -112,24 +112,42 @@ async function main() {
|
|
|
112
112
|
if (agent === currentAgent) continue;
|
|
113
113
|
for (const scope of scopes) {
|
|
114
114
|
if (relativePath.startsWith(scope) || (!scope.includes('/') && relativePath.endsWith(scope))) {
|
|
115
|
-
process.stdout.write(JSON.stringify(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}));
|
|
115
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
116
|
+
`[Team Quality Gate] ${currentAgent} cannot write to ${relativePath} — this path belongs to ${agent}. Use the team mailbox to coordinate changes (Article XXI).`
|
|
117
|
+
)));
|
|
119
118
|
return;
|
|
120
119
|
}
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
// Path not owned by anyone specific → allow
|
|
125
|
-
process.stdout.write(JSON.stringify(
|
|
124
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
126
125
|
} catch (err) {
|
|
127
126
|
// Fail-open: hook errors should not block execution
|
|
128
127
|
process.stderr.write(`[chati] team-quality-gate: ${err.message}\n`);
|
|
129
|
-
process.stdout.write(JSON.stringify(
|
|
128
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
130
129
|
}
|
|
131
130
|
}
|
|
132
131
|
|
|
132
|
+
function allowOutput() {
|
|
133
|
+
return {
|
|
134
|
+
hookSpecificOutput: {
|
|
135
|
+
hookEventName: 'PreToolUse',
|
|
136
|
+
permissionDecision: 'allow',
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function denyOutput(reason) {
|
|
142
|
+
return {
|
|
143
|
+
hookSpecificOutput: {
|
|
144
|
+
hookEventName: 'PreToolUse',
|
|
145
|
+
permissionDecision: 'deny',
|
|
146
|
+
permissionDecisionReason: reason,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
133
151
|
function detectAgentFromSession(projectDir) {
|
|
134
152
|
const sessionPath = join(projectDir, '.chati', 'session.yaml');
|
|
135
153
|
if (!existsSync(sessionPath)) return null;
|
|
@@ -138,8 +156,16 @@ function detectAgentFromSession(projectDir) {
|
|
|
138
156
|
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
// Only run main when executed directly
|
|
159
|
+
// Only run main when executed directly (not imported by tests).
|
|
160
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
161
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
162
|
+
// silently and main() never runs.
|
|
142
163
|
import { fileURLToPath } from 'url';
|
|
143
|
-
|
|
144
|
-
|
|
164
|
+
import { realpathSync } from 'fs';
|
|
165
|
+
if (process.argv[1]) {
|
|
166
|
+
try {
|
|
167
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
168
|
+
main();
|
|
169
|
+
}
|
|
170
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
145
171
|
}
|
|
@@ -173,14 +173,14 @@ async function main() {
|
|
|
173
173
|
|
|
174
174
|
// Check if undercover mode is enabled
|
|
175
175
|
if (!isUndercoverEnabled(projectDir)) {
|
|
176
|
-
process.stdout.write(JSON.stringify(
|
|
176
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
177
177
|
return;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
// Skip framework-internal files
|
|
181
181
|
const filePath = toolInput.file_path || '';
|
|
182
182
|
if (isExemptPath(filePath, projectDir)) {
|
|
183
|
-
process.stdout.write(JSON.stringify(
|
|
183
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
|
|
@@ -196,25 +196,44 @@ async function main() {
|
|
|
196
196
|
|
|
197
197
|
if (scan.found) {
|
|
198
198
|
const terms = scan.matches.map(m => `"${m.term}" → "${m.replacement}"`).join(', ');
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
// Advisory only — surface findings as reason, allow operation.
|
|
200
|
+
process.stdout.write(JSON.stringify(allowOutput(
|
|
201
|
+
`[Undercover] Internal references detected: ${terms}. Sanitize before shipping. Replace internal terms with their generic equivalents.`
|
|
202
|
+
)));
|
|
203
203
|
return;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
process.stdout.write(JSON.stringify(
|
|
206
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
207
207
|
} catch (err) {
|
|
208
208
|
process.stderr.write(`[chati-hook-error] undercover-guard: ${err?.message || 'unknown'}\n`);
|
|
209
209
|
// Fail-open: undercover is advisory, not security-critical
|
|
210
|
-
process.stdout.write(JSON.stringify(
|
|
210
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
function allowOutput(reason) {
|
|
215
|
+
const out = {
|
|
216
|
+
hookSpecificOutput: {
|
|
217
|
+
hookEventName: 'PreToolUse',
|
|
218
|
+
permissionDecision: 'allow',
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
214
225
|
export { scanForInternals, scanBashForInternals, isExemptPath, isUndercoverEnabled, UNDERCOVER_RULES };
|
|
215
226
|
|
|
216
|
-
// Only run main when executed directly (not imported by tests)
|
|
227
|
+
// Only run main when executed directly (not imported by tests).
|
|
228
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
229
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
230
|
+
// silently and main() never runs.
|
|
217
231
|
import { fileURLToPath } from 'url';
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
import { realpathSync } from 'fs';
|
|
233
|
+
if (process.argv[1]) {
|
|
234
|
+
try {
|
|
235
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
236
|
+
main();
|
|
237
|
+
}
|
|
238
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
220
239
|
}
|