chati-dev 4.2.1 → 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 +2 -1
- package/framework/context/root.md +2 -2
- 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 +146 -8
- package/src/config/claude-settings-generator.js +206 -0
- package/src/config/gemini-hooks-generator.js +58 -0
- package/src/installer/core.js +136 -1
- package/src/installer/templates.js +6 -2
- package/src/orchestrator/cli.js +41 -11
package/framework/config.yaml
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# chati.dev Configuration
|
|
2
|
-
version: "4.2.
|
|
2
|
+
version: "4.2.2"
|
|
3
3
|
installed_at: "2026-02-07T10:00:00Z"
|
|
4
4
|
updated_at: "2026-04-11T00:00:00Z"
|
|
5
|
-
installer_version: "4.2.
|
|
5
|
+
installer_version: "4.2.2"
|
|
6
6
|
project_type: greenfield
|
|
7
7
|
language: en
|
|
8
8
|
ides: [claude-code]
|
|
@@ -54,7 +54,7 @@ features:
|
|
|
54
54
|
frustration_detection: true # Detect user frustration and adapt response style
|
|
55
55
|
bash_security_checks: true # 23-point shell injection defense system
|
|
56
56
|
# Agent Teams (v4.2.0 — Article XXI)
|
|
57
|
-
agent_teams:
|
|
57
|
+
agent_teams: true # Enable Claude Code native Agent Teams. Gated to claude provider; Gemini/Codex fall back silently.
|
|
58
58
|
team_planning_size: 3 # Planning Team max teammates: detail + architect + ux
|
|
59
59
|
team_build_size: 2 # Build Team max teammates: dev + qa-implementation
|
|
60
60
|
team_echo_threshold: 0.92 # Content similarity ratio to trigger Echo Detection (Article XXII)
|
|
@@ -755,7 +755,8 @@ If any check is NO, the compliance block itself is an ERROR and the report canno
|
|
|
755
755
|
|
|
756
756
|
---
|
|
757
757
|
|
|
758
|
-
*Chati.dev Constitution v4.2.
|
|
758
|
+
*Chati.dev Constitution v4.2.2 — 22 Articles + Preamble*
|
|
759
759
|
*v4.2.0 Amendments: Article V amended (Team Communication); Article VIII amended (Team Handoff Envelope); Article XII amended (PRISM L6 Team Roster); Article XXI added (Agent Teams Governance); Article XXII added (Fault Vector Protocol)*
|
|
760
760
|
*v4.2.1 Fixes: installer refactor (recursive copy), features block in config, context files sync*
|
|
761
|
+
*v4.2.2 Fixes: 10 hooks rewritten to canonical hookSpecificOutput schema; .claude/settings.json now actually written by installer (CRITICAL — hooks were dormant before); license enforcement extended to Gemini and Codex per-turn; Agent Teams default ON; orchestrator jargon removed from user-facing output*
|
|
761
762
|
*All agents are bound by this Constitution. Violations are enforced per article.*
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Chati.dev System Context
|
|
2
2
|
|
|
3
3
|
## Framework
|
|
4
|
-
- **Version**: 4.2.
|
|
4
|
+
- **Version**: 4.2.2
|
|
5
5
|
- **Agents**: Specialized agents across DISCOVER, PLAN, BUILD, DEPLOY phases
|
|
6
6
|
- **Constitution**: 22 Articles + Preamble
|
|
7
7
|
- **Quality**: 5 pipeline gates + 3-tier verdicts + Fault Vector Protocol (Article XXII)
|
|
8
|
-
- **Agent Teams**: Native Claude Code teams with peer communication (Article XXI,
|
|
8
|
+
- **Agent Teams**: Native Claude Code teams with peer communication (Article XXI, default ON in v4.2.2 for Claude provider)
|
|
9
9
|
|
|
10
10
|
## Key References
|
|
11
11
|
- **Session State**: `.chati/session.yaml` (runtime — not committed)
|
|
@@ -81,10 +81,9 @@ async function main() {
|
|
|
81
81
|
const secrets = containsSecrets(content);
|
|
82
82
|
|
|
83
83
|
if (secrets.length > 0) {
|
|
84
|
-
process.stdout.write(JSON.stringify(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}));
|
|
84
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
85
|
+
`[Article IV] Potential secret detected in file content. Pattern: ${secrets[0]}. Use environment variables instead.`
|
|
86
|
+
)));
|
|
88
87
|
return;
|
|
89
88
|
}
|
|
90
89
|
}
|
|
@@ -93,10 +92,9 @@ async function main() {
|
|
|
93
92
|
if (toolName === 'Bash') {
|
|
94
93
|
const command = toolInput.command || '';
|
|
95
94
|
if (isDestructiveCommand(command)) {
|
|
96
|
-
process.stdout.write(JSON.stringify(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}));
|
|
95
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
96
|
+
`[Article IV] Destructive command detected: "${command.slice(0, 60)}...". This requires explicit user confirmation.`
|
|
97
|
+
)));
|
|
100
98
|
return;
|
|
101
99
|
}
|
|
102
100
|
|
|
@@ -105,28 +103,49 @@ async function main() {
|
|
|
105
103
|
if (injectionFindings.length > 0) {
|
|
106
104
|
const critical = injectionFindings.filter(f => f.severity === 'critical');
|
|
107
105
|
if (critical.length > 0) {
|
|
108
|
-
process.stdout.write(JSON.stringify(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}));
|
|
106
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
107
|
+
`[Article IV] Shell injection risk detected (${critical.length} critical): ${critical.map(f => f.id).join(', ')}. Command: "${command.slice(0, 60)}..."`
|
|
108
|
+
)));
|
|
112
109
|
return;
|
|
113
110
|
}
|
|
114
|
-
// High/medium findings: allow
|
|
115
|
-
process.stdout.write(JSON.stringify(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}));
|
|
111
|
+
// High/medium findings: allow (advisory only, surfaced via reason)
|
|
112
|
+
process.stdout.write(JSON.stringify(allowOutput(
|
|
113
|
+
`[Security Advisory] ${injectionFindings.length} shell security finding(s): ${injectionFindings.map(f => f.id).join(', ')}`
|
|
114
|
+
)));
|
|
119
115
|
return;
|
|
120
116
|
}
|
|
121
117
|
}
|
|
122
118
|
|
|
123
|
-
process.stdout.write(JSON.stringify(
|
|
119
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
124
120
|
} catch (err) {
|
|
125
121
|
process.stderr.write(`[chati-hook-error] constitution-guard: ${err?.message || 'unknown'}\n`);
|
|
126
|
-
|
|
122
|
+
// Fail-open on hook errors: a broken hook should not block legitimate work.
|
|
123
|
+
// Real security checks above are explicit and run before this catch.
|
|
124
|
+
process.stdout.write(JSON.stringify(allowOutput()));
|
|
127
125
|
}
|
|
128
126
|
}
|
|
129
127
|
|
|
128
|
+
function allowOutput(reason) {
|
|
129
|
+
const out = {
|
|
130
|
+
hookSpecificOutput: {
|
|
131
|
+
hookEventName: 'PreToolUse',
|
|
132
|
+
permissionDecision: 'allow',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
if (reason) out.hookSpecificOutput.permissionDecisionReason = reason;
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function denyOutput(reason) {
|
|
140
|
+
return {
|
|
141
|
+
hookSpecificOutput: {
|
|
142
|
+
hookEventName: 'PreToolUse',
|
|
143
|
+
permissionDecision: 'deny',
|
|
144
|
+
permissionDecisionReason: reason,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
/**
|
|
131
150
|
* 23-point shell injection security checks.
|
|
132
151
|
* Inline implementation (hooks are standalone — no imports from src/).
|
|
@@ -135,15 +154,24 @@ async function main() {
|
|
|
135
154
|
// SYNC: These 23 check IDs MUST match packages/chati-dev/src/security/bash-security.js
|
|
136
155
|
// Hooks are standalone (no imports from src/), so duplication is necessary.
|
|
137
156
|
// If you update here, update bash-security.js too. Run: npm test to verify both.
|
|
157
|
+
// Severity tiers:
|
|
158
|
+
// - critical = block immediately (clearly malicious, no legitimate use)
|
|
159
|
+
// - high/medium = advisory only (surfaced as reason, allowed). Used to be
|
|
160
|
+
// blocking, but several patterns produced too many false positives
|
|
161
|
+
// (e.g. SHELL_METACHARACTERS firing on every $(date), GIT_COMMIT_SUBSTITUTION
|
|
162
|
+
// firing on git commit -m "$(date)"). Critical patterns below are narrowly
|
|
163
|
+
// scoped to actually-dangerous shell injection vectors.
|
|
138
164
|
const SHELL_INJECTION_CHECKS = [
|
|
139
|
-
{ id: 'INCOMPLETE_COMMANDS', pattern: /[|&;]\s*$/, severity: '
|
|
165
|
+
{ id: 'INCOMPLETE_COMMANDS', pattern: /[|&;]\s*$/, severity: 'medium' },
|
|
140
166
|
{ id: 'JQ_SYSTEM_FUNCTION', pattern: /jq\b.*\bsystem\s*\(/i, severity: 'critical' },
|
|
141
|
-
{ id: 'JQ_FILE_ARGUMENTS', pattern: /jq\b.*--from-file|jq\b.*-f\s+[^|&;]+/i, severity: '
|
|
142
|
-
{ id: 'OBFUSCATED_FLAGS', pattern: /\$[({].*[)}].*-/, severity: '
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{ id: '
|
|
146
|
-
|
|
167
|
+
{ id: 'JQ_FILE_ARGUMENTS', pattern: /jq\b.*--from-file|jq\b.*-f\s+[^|&;]+/i, severity: 'medium' },
|
|
168
|
+
{ id: 'OBFUSCATED_FLAGS', pattern: /\$[({].*[)}].*-/, severity: 'medium' },
|
|
169
|
+
// SHELL_METACHARACTERS removed: $() and backticks are normal substitution.
|
|
170
|
+
// Genuinely dangerous substitutions are caught by DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION below.
|
|
171
|
+
{ id: 'DANGEROUS_VARIABLES', pattern: /(?:^|\s)(?:LD_PRELOAD|LD_LIBRARY_PATH|DYLD_INSERT_LIBRARIES|PYTHONPATH|NODE_PATH|RUBYLIB|PERL5LIB)\s*=/, severity: 'critical' },
|
|
172
|
+
// PATH= removed from critical (legitimate `PATH=/foo:$PATH cmd` exists).
|
|
173
|
+
{ id: 'NEWLINES', pattern: /(?<!\\)\n.*(?:rm|curl|wget|chmod|chown|sudo|eval|exec)/, severity: 'medium' },
|
|
174
|
+
{ id: 'BACKSLASH_ESCAPED_WHITESPACE', pattern: /\\\s+(?:-|\/)/, severity: 'medium' },
|
|
147
175
|
// eslint-disable-next-line no-control-regex
|
|
148
176
|
{ id: 'CONTROL_CHARACTERS', pattern: /[\x00-\x08\x0e-\x1f\x7f]/, severity: 'critical' },
|
|
149
177
|
{ id: 'UNICODE_WHITESPACE', pattern: /[\u200B-\u200F\u2028-\u202F\uFEFF\u00A0\u2060\u180E]/, severity: 'critical' },
|
|
@@ -151,13 +179,13 @@ const SHELL_INJECTION_CHECKS = [
|
|
|
151
179
|
{ id: 'DANGEROUS_PATTERNS_INPUT_REDIRECTION', pattern: /<\s*(?:\/etc\/(?:passwd|shadow|sudoers)|\/proc\/|~\/\.ssh\/|~\/\.aws\/)/, severity: 'critical' },
|
|
152
180
|
{ id: 'DANGEROUS_PATTERNS_OUTPUT_REDIRECTION', pattern: />\s*(?:\/etc\/|~\/\.ssh\/|~\/\.bashrc|~\/\.zshrc|~\/\.profile|~\/\.gitconfig)/, severity: 'critical' },
|
|
153
181
|
{ id: 'IFS_INJECTION', pattern: /\bIFS\s*=/, severity: 'critical' },
|
|
154
|
-
{ id: 'BRACE_EXPANSION', pattern: /\{.*(?:rm|curl|wget|chmod|eval|exec|sudo).*[,}]/, severity: '
|
|
155
|
-
|
|
182
|
+
{ id: 'BRACE_EXPANSION', pattern: /\{.*(?:rm|curl|wget|chmod|eval|exec|sudo).*[,}]/, severity: 'medium' },
|
|
183
|
+
// GIT_COMMIT_SUBSTITUTION removed: `git commit -m "$(date)"` is legitimate.
|
|
156
184
|
{ id: 'PROC_ENVIRON_ACCESS', pattern: /\/proc\/(?:self|\d+)\/(?:environ|cmdline|maps|mem)/, severity: 'critical' },
|
|
157
|
-
{ id: 'MALFORMED_TOKEN_INJECTION', pattern: /\\x[0-9a-f]{2}|\\u[0-9a-f]{4}|\\[0-7]{3}/i, severity: '
|
|
185
|
+
{ id: 'MALFORMED_TOKEN_INJECTION', pattern: /\\x[0-9a-f]{2}|\\u[0-9a-f]{4}|\\[0-7]{3}/i, severity: 'medium' },
|
|
158
186
|
{ id: 'MID_WORD_HASH', pattern: /\w#\w/, severity: 'medium' },
|
|
159
187
|
{ id: 'COMMENT_QUOTE_DESYNC', pattern: /#.*['"][^'"]*$/, severity: 'medium' },
|
|
160
|
-
{ id: 'QUOTED_NEWLINE', pattern: /["'][^"']*\n[^"']*["']/, severity: '
|
|
188
|
+
{ id: 'QUOTED_NEWLINE', pattern: /["'][^"']*\n[^"']*["']/, severity: 'medium' },
|
|
161
189
|
{ id: 'ZSH_DANGEROUS_COMMANDS', pattern: /\b(?:zmodload|sysopen|sysread|syswrite|zsystem|zselect|ztcp)\b/, severity: 'critical' },
|
|
162
190
|
{ id: 'BACKSLASH_ESCAPED_OPERATORS', pattern: /\\[|;&]/, severity: 'medium' },
|
|
163
191
|
];
|
|
@@ -176,8 +204,16 @@ function runShellInjectionChecks(command) {
|
|
|
176
204
|
|
|
177
205
|
export { containsSecrets, isDestructiveCommand, runShellInjectionChecks, SECRET_PATTERNS, DESTRUCTIVE_COMMANDS, SHELL_INJECTION_CHECKS };
|
|
178
206
|
|
|
179
|
-
// Only run main when executed directly (not imported by tests)
|
|
207
|
+
// Only run main when executed directly (not imported by tests).
|
|
208
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
209
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
210
|
+
// silently and main() never runs.
|
|
180
211
|
import { fileURLToPath } from 'url';
|
|
181
|
-
|
|
182
|
-
|
|
212
|
+
import { realpathSync } from 'fs';
|
|
213
|
+
if (process.argv[1]) {
|
|
214
|
+
try {
|
|
215
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
216
|
+
main();
|
|
217
|
+
}
|
|
218
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
183
219
|
}
|
|
@@ -3,217 +3,110 @@
|
|
|
3
3
|
* License Guard Hook — UserPromptSubmit
|
|
4
4
|
*
|
|
5
5
|
* Validates license status before each user prompt turn.
|
|
6
|
-
* Uses a
|
|
6
|
+
* Uses a 4-hour cache stored in ~/.chati-dev/license.yaml (global, all projects).
|
|
7
7
|
*
|
|
8
8
|
* Behavior:
|
|
9
|
-
* - No
|
|
10
|
-
* - Cache VALID <
|
|
11
|
-
* - Cache EXPIRED/INVALID
|
|
12
|
-
* - Cache stale (>
|
|
13
|
-
* - API unreachable
|
|
9
|
+
* - No license file → block with activation instructions
|
|
10
|
+
* - Cache VALID < 4h → allow silently
|
|
11
|
+
* - Cache EXPIRED/INVALID → block with renewal message
|
|
12
|
+
* - Cache stale (> 4h) → call API, refresh cache, then allow or block
|
|
13
|
+
* - API unreachable + cache VALID < 24h → grace allow
|
|
14
|
+
* - API unreachable + cache stale → block (cannot verify)
|
|
15
|
+
*
|
|
16
|
+
* Telemetry pings are owned by session-digest.js (PreCompact event), not here.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
17
|
-
import { join
|
|
20
|
+
import { join } from 'path';
|
|
18
21
|
import { homedir } from 'os';
|
|
19
22
|
|
|
20
23
|
const API_BASE = 'https://chati.dev/api';
|
|
21
|
-
const
|
|
22
|
-
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours — shorter window to catch expirations faster
|
|
23
|
-
const PING_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
|
|
24
|
+
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
24
25
|
|
|
25
26
|
const GLOBAL_DIR = join(homedir(), '.chati-dev');
|
|
26
27
|
const LICENSE_PATH = join(GLOBAL_DIR, 'license.yaml');
|
|
27
|
-
const PINGS_PATH = join(GLOBAL_DIR, 'pings.yaml');
|
|
28
|
-
|
|
29
|
-
async function main() {
|
|
30
|
-
let input = '';
|
|
31
|
-
for await (const chunk of process.stdin) input += chunk;
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
// Read global license file — REQUIRED for operation
|
|
35
|
-
if (!existsSync(LICENSE_PATH)) {
|
|
36
|
-
block('No license found. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing');
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (age < CACHE_TTL_MS) {
|
|
54
|
-
if (status === 'VALID') {
|
|
55
|
-
await allowWithPing(licenseKey);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (status === 'EXPIRED' || status === 'INVALID') {
|
|
59
|
-
block(buildMessage(status, readYamlField(licenseRaw, 'reason')));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Cache stale — call API
|
|
65
|
-
try {
|
|
66
|
-
const machineId = await computeMachineId();
|
|
67
|
-
const res = await fetch(
|
|
68
|
-
`${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
|
|
69
|
-
{ signal: AbortSignal.timeout(5000) }
|
|
70
|
-
);
|
|
71
|
-
const data = await res.json();
|
|
29
|
+
/**
|
|
30
|
+
* Pure license validation function — provider-agnostic.
|
|
31
|
+
* Returns a verdict object that hook wrappers can translate to their
|
|
32
|
+
* provider's blocking schema (Claude UserPromptSubmit, Gemini BeforeModel,
|
|
33
|
+
* Codex UserPromptSubmit).
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<{valid: boolean, reason?: string, status?: string}>}
|
|
36
|
+
*/
|
|
37
|
+
export async function checkLicense() {
|
|
38
|
+
// Read global license file — REQUIRED for operation
|
|
39
|
+
if (!existsSync(LICENSE_PATH)) {
|
|
40
|
+
return { valid: false, reason: 'No license found. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing', status: 'MISSING' };
|
|
41
|
+
}
|
|
72
42
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const updated = {
|
|
76
|
-
...existing,
|
|
77
|
-
status: data.status,
|
|
78
|
-
plan: data.plan ?? '',
|
|
79
|
-
days_remaining: data.days_remaining ?? '',
|
|
80
|
-
expires_at: data.expires_at ?? '',
|
|
81
|
-
reason: data.reason ?? '',
|
|
82
|
-
checked_at: new Date().toISOString(),
|
|
83
|
-
};
|
|
84
|
-
mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
85
|
-
writeFileSync(LICENSE_PATH, dumpYaml(updated));
|
|
43
|
+
const licenseRaw = readFileSync(LICENSE_PATH, 'utf-8');
|
|
44
|
+
const licenseKey = readYamlField(licenseRaw, 'key');
|
|
86
45
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
block(buildMessage(data.status, data.reason));
|
|
92
|
-
} catch {
|
|
93
|
-
// API unreachable — check if we have a recent VALID cache to fall back on
|
|
94
|
-
// If cache is less than 24h old AND was VALID, allow (grace period for network issues)
|
|
95
|
-
// Otherwise block — we cannot verify the license
|
|
96
|
-
if (age < 24 * 60 * 60 * 1000 && status === 'VALID') {
|
|
97
|
-
allow(); // grace: last check was recent and valid, allow despite network issue
|
|
98
|
-
} else {
|
|
99
|
-
block('Unable to verify license (network issue). If this persists, check your connection.\nRun: npx chati-dev activate --key=YOUR-KEY');
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
} catch { /* expected: stdin parse may fail — fail open */
|
|
103
|
-
allow();
|
|
46
|
+
if (!licenseKey || licenseKey === 'null') {
|
|
47
|
+
return { valid: false, reason: 'No license key configured. Run: npx chati-dev activate --key=YOUR-KEY\nGet a license at https://chati.dev/pricing', status: 'MISSING' };
|
|
104
48
|
}
|
|
105
|
-
}
|
|
106
49
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
async function allowWithPing(licenseKey) {
|
|
112
|
-
const projectPath = process.cwd();
|
|
113
|
-
const projectName = basename(projectPath) || 'unknown';
|
|
114
|
-
const session = readSessionContext();
|
|
50
|
+
// Check cache validity
|
|
51
|
+
const status = readYamlField(licenseRaw, 'status');
|
|
52
|
+
const checkedAt = readYamlField(licenseRaw, 'checked_at');
|
|
53
|
+
const age = checkedAt ? Date.now() - new Date(checkedAt).getTime() : Infinity;
|
|
115
54
|
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
} else {
|
|
122
|
-
allow();
|
|
55
|
+
if (age < CACHE_TTL_MS) {
|
|
56
|
+
if (status === 'VALID') return { valid: true, status: 'VALID' };
|
|
57
|
+
if (status === 'EXPIRED' || status === 'INVALID') {
|
|
58
|
+
return { valid: false, reason: buildMessage(status, readYamlField(licenseRaw, 'reason')), status };
|
|
59
|
+
}
|
|
123
60
|
}
|
|
124
|
-
}
|
|
125
61
|
|
|
126
|
-
|
|
127
|
-
* Reads .chati/session.yaml from cwd to extract pipeline phase, agent, and project type.
|
|
128
|
-
* Returns {} on any error (fail silently — this is optional enrichment).
|
|
129
|
-
*/
|
|
130
|
-
function readSessionContext() {
|
|
62
|
+
// Cache stale — call API
|
|
131
63
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
64
|
+
const machineId = await computeMachineId();
|
|
65
|
+
const res = await fetch(
|
|
66
|
+
`${API_BASE}/license/validate?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
|
|
67
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
68
|
+
);
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
|
|
71
|
+
// Persist updated status (keep existing key)
|
|
72
|
+
const existing = parseYaml(licenseRaw);
|
|
73
|
+
const updated = {
|
|
74
|
+
...existing,
|
|
75
|
+
status: data.status,
|
|
76
|
+
plan: data.plan ?? '',
|
|
77
|
+
days_remaining: data.days_remaining ?? '',
|
|
78
|
+
expires_at: data.expires_at ?? '',
|
|
79
|
+
reason: data.reason ?? '',
|
|
80
|
+
checked_at: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
83
|
+
writeFileSync(LICENSE_PATH, dumpYaml(updated));
|
|
84
|
+
|
|
85
|
+
if (data.status === 'VALID') return { valid: true, status: 'VALID' };
|
|
86
|
+
return { valid: false, reason: buildMessage(data.status, data.reason), status: data.status };
|
|
87
|
+
} catch {
|
|
88
|
+
// API unreachable — grace period if cache is recent and was VALID
|
|
89
|
+
if (age < 24 * 60 * 60 * 1000 && status === 'VALID') {
|
|
90
|
+
return { valid: true, status: 'VALID', graced: true };
|
|
91
|
+
}
|
|
92
|
+
return { valid: false, reason: 'Unable to verify license (network issue). If this persists, check your connection.\nRun: npx chati-dev activate --key=YOUR-KEY', status: 'NETWORK_ERROR' };
|
|
93
|
+
}
|
|
150
94
|
}
|
|
151
95
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
*/
|
|
156
|
-
function shouldPing(projectPath, session = {}) {
|
|
157
|
-
try {
|
|
158
|
-
if (!existsSync(PINGS_PATH)) return true;
|
|
159
|
-
const content = readFileSync(PINGS_PATH, 'utf-8');
|
|
160
|
-
const escaped = projectPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
161
|
-
const match = content.match(new RegExp(`^${escaped}:\\s*'?([^'\\n]+)'?$`, 'm'));
|
|
162
|
-
if (!match) return true;
|
|
163
|
-
|
|
164
|
-
const [timestamp, lastPhase, lastAgent] = match[1].trim().split('|');
|
|
165
|
-
const age = Date.now() - new Date(timestamp).getTime();
|
|
166
|
-
if (age > PING_THROTTLE_MS) return true;
|
|
167
|
-
|
|
168
|
-
// Phase or agent changed → ping immediately
|
|
169
|
-
if (session.pipeline_phase && session.pipeline_phase !== lastPhase) return true;
|
|
170
|
-
if (session.current_agent && session.current_agent !== lastAgent) return true;
|
|
171
|
-
|
|
172
|
-
return false;
|
|
173
|
-
} catch { return false; } // fail closed — don't add latency on parse error
|
|
174
|
-
}
|
|
96
|
+
async function main() {
|
|
97
|
+
let input = '';
|
|
98
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
175
99
|
|
|
176
|
-
function recordPing(projectPath, session = {}) {
|
|
177
100
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const agent = session.current_agent ?? '';
|
|
182
|
-
const value = `${timestamp}|${phase}|${agent}`;
|
|
183
|
-
|
|
184
|
-
let content = existsSync(PINGS_PATH) ? readFileSync(PINGS_PATH, 'utf-8') : '';
|
|
185
|
-
const escaped = projectPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
186
|
-
if (new RegExp(`^${escaped}:`, 'm').test(content)) {
|
|
187
|
-
content = content.replace(new RegExp(`^(${escaped}:).*$`, 'm'), `$1 '${value}'`);
|
|
101
|
+
const result = await checkLicense();
|
|
102
|
+
if (result.valid) {
|
|
103
|
+
allow();
|
|
188
104
|
} else {
|
|
189
|
-
|
|
105
|
+
block(result.reason);
|
|
190
106
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function sendSessionPing(licenseKey, projectName, session = {}) {
|
|
196
|
-
try {
|
|
197
|
-
const properties = {};
|
|
198
|
-
if (session.pipeline_phase) properties.pipeline_phase = session.pipeline_phase;
|
|
199
|
-
if (session.current_agent) properties.current_agent = session.current_agent;
|
|
200
|
-
if (session.project_type) properties.project_type = session.project_type;
|
|
201
|
-
|
|
202
|
-
await fetch(TELEMETRY_ENDPOINT, {
|
|
203
|
-
method: 'POST',
|
|
204
|
-
headers: { 'Content-Type': 'application/json' },
|
|
205
|
-
body: JSON.stringify({
|
|
206
|
-
license_key: licenseKey,
|
|
207
|
-
project_name: projectName,
|
|
208
|
-
events: [{
|
|
209
|
-
type: 'session_active',
|
|
210
|
-
timestamp: new Date().toISOString(),
|
|
211
|
-
properties,
|
|
212
|
-
}],
|
|
213
|
-
}),
|
|
214
|
-
signal: AbortSignal.timeout(3000),
|
|
215
|
-
});
|
|
216
|
-
} catch {} // fire-and-forget, always fail silently
|
|
107
|
+
} catch { /* expected: stdin parse may fail — fail open */
|
|
108
|
+
allow();
|
|
109
|
+
}
|
|
217
110
|
}
|
|
218
111
|
|
|
219
112
|
// ---------------------------------------------------------------------------
|
|
@@ -221,10 +114,12 @@ async function sendSessionPing(licenseKey, projectName, session = {}) {
|
|
|
221
114
|
// ---------------------------------------------------------------------------
|
|
222
115
|
|
|
223
116
|
function allow() {
|
|
224
|
-
|
|
117
|
+
// UserPromptSubmit canonical "allow" is empty object — omit decision.
|
|
118
|
+
process.stdout.write('{}');
|
|
225
119
|
}
|
|
226
120
|
|
|
227
121
|
function block(reason) {
|
|
122
|
+
// UserPromptSubmit blocking schema: decision + reason at top level.
|
|
228
123
|
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
229
124
|
}
|
|
230
125
|
|
|
@@ -274,7 +169,16 @@ async function computeMachineId() {
|
|
|
274
169
|
return createHash('sha256').update(raw).digest('hex').substring(0, 16);
|
|
275
170
|
}
|
|
276
171
|
|
|
172
|
+
// Only run main when executed directly (not imported by tests).
|
|
173
|
+
// realpathSync resolves symlinks on both sides — important on macOS where
|
|
174
|
+
// /tmp is a symlink to /private/tmp. Without realpath the comparison fails
|
|
175
|
+
// silently and main() never runs.
|
|
277
176
|
import { fileURLToPath } from 'url';
|
|
278
|
-
|
|
279
|
-
|
|
177
|
+
import { realpathSync } from 'fs';
|
|
178
|
+
if (process.argv[1]) {
|
|
179
|
+
try {
|
|
180
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
181
|
+
main();
|
|
182
|
+
}
|
|
183
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
280
184
|
}
|
|
@@ -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
|
}
|