@sanctix/client 0.1.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/LICENSE +21 -0
- package/README.md +25 -0
- package/bin/sanctix.js +48 -0
- package/cli/init.js +278 -0
- package/cli/runtime.js +85 -0
- package/cli/start.js +165 -0
- package/cli/status.js +103 -0
- package/cli/stop.js +134 -0
- package/cli/uninstall.js +195 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/check-agent-spawn-result.cjs +60 -0
- package/hooks/check-agent-spawn.cjs +60 -0
- package/hooks/check-bash.cjs +75 -0
- package/hooks/check-file-edit-result.cjs +63 -0
- package/hooks/check-file-edit.cjs +63 -0
- package/hooks/check-subagent-start.cjs +59 -0
- package/hooks/post-audit-event.cjs +50 -0
- package/hooks/tool-capture.cjs +138 -0
- package/package.json +37 -0
- package/templates/.gitkeep +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruvoni Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Sanctix Client
|
|
2
|
+
|
|
3
|
+
Governed edge client for Claude Code, Codex and Cursor.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
npm install -g @sanctix/client
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
sanctix init --hosted --invite <code>
|
|
12
|
+
|
|
13
|
+
## What this package contains
|
|
14
|
+
|
|
15
|
+
- sanctix CLI (init, check, status, uninstall)
|
|
16
|
+
- Hook shims for Claude Code, Codex and Cursor
|
|
17
|
+
- Runtime templates
|
|
18
|
+
|
|
19
|
+
## What this package does NOT contain
|
|
20
|
+
|
|
21
|
+
Scoring engine, failure memory, trust capability profiles,
|
|
22
|
+
audit chain internals, governance policy logic or commercial
|
|
23
|
+
adapter code. All governance runs server-side.
|
|
24
|
+
|
|
25
|
+
Docs: https://sanctix.ai
|
package/bin/sanctix.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { init } from '../cli/init.js';
|
|
4
|
+
import { uninstall } from '../cli/uninstall.js';
|
|
5
|
+
import { status } from '../cli/status.js';
|
|
6
|
+
import { start } from '../cli/start.js';
|
|
7
|
+
import { stop } from '../cli/stop.js';
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('sanctix')
|
|
11
|
+
.description('Sanctix governed edge client')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command('init')
|
|
16
|
+
.description('Activate Sanctix governance in this repo')
|
|
17
|
+
.option('--hosted', 'Connect to hosted Sanctix endpoint')
|
|
18
|
+
.option('--invite <code>', 'Invite code for hosted mode')
|
|
19
|
+
.option('--audit-url <url>', 'Custom audit service URL')
|
|
20
|
+
.option('-y, --yes', 'Skip confirmations')
|
|
21
|
+
.action(init);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('uninstall')
|
|
25
|
+
.description('Remove Sanctix from this repo')
|
|
26
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
27
|
+
.action(uninstall);
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('status')
|
|
31
|
+
.description('Check Sanctix governance health')
|
|
32
|
+
.action(status);
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('start <task>')
|
|
36
|
+
.description('Start a governed session and get export commands')
|
|
37
|
+
.option('--risk <level>', 'Risk level: low, medium, high')
|
|
38
|
+
.option('--runtime <runtime>', 'Runtime: claude_code, codex, cursor')
|
|
39
|
+
.option('-y, --yes', 'Skip confirmation for high-risk tasks')
|
|
40
|
+
.action(start);
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command('stop')
|
|
44
|
+
.description('Complete the active governed session')
|
|
45
|
+
.option('-y, --yes', 'Skip confirmation')
|
|
46
|
+
.action(stop);
|
|
47
|
+
|
|
48
|
+
program.parse();
|
package/cli/init.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import {
|
|
7
|
+
HOOK_FILES,
|
|
8
|
+
CLAUDE_HOOK_LAYOUT,
|
|
9
|
+
CLAUDE_MANAGED_FILES,
|
|
10
|
+
detectRuntime,
|
|
11
|
+
httpRequestJson,
|
|
12
|
+
} from './runtime.js';
|
|
13
|
+
|
|
14
|
+
const __filename = url.fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const HOOKS_SOURCE_DIR = path.resolve(__dirname, '../hooks');
|
|
17
|
+
|
|
18
|
+
const HOSTED_AUDIT_URL = 'https://awf.ruvoni.com';
|
|
19
|
+
|
|
20
|
+
const SANCTIX_HOOK_COMMANDS = {
|
|
21
|
+
PreToolUse: [
|
|
22
|
+
{ matcher: 'Bash', command: 'node .claude/hooks/pre-tool-use/check-bash.cjs' },
|
|
23
|
+
{ matcher: '.*', command: 'node .claude/hooks/pre-tool-use/check-file-edit.cjs' },
|
|
24
|
+
{ matcher: 'Agent', command: 'node .claude/hooks/pre-tool-use/check-agent-spawn.cjs' },
|
|
25
|
+
],
|
|
26
|
+
PostToolUse: [
|
|
27
|
+
{ matcher: 'Agent', command: 'node .claude/hooks/post-tool-use/check-agent-spawn-result.cjs' },
|
|
28
|
+
{ matcher: '.*', command: 'node .claude/hooks/post-tool-use/check-file-edit-result.cjs' },
|
|
29
|
+
],
|
|
30
|
+
SubagentStart: [
|
|
31
|
+
{ matcher: null, command: 'node .claude/hooks/sub-agent-start/check-subagent-start.cjs' },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function init(options) {
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
|
|
38
|
+
// Step 1: Resolve audit URL and API key.
|
|
39
|
+
let auditUrl;
|
|
40
|
+
let apiKey;
|
|
41
|
+
let hosted = false;
|
|
42
|
+
|
|
43
|
+
if (options.hosted) {
|
|
44
|
+
if (!options.invite) {
|
|
45
|
+
console.error(chalk.red('Hosted mode requires --invite <code>'));
|
|
46
|
+
console.error('Get an invite code at https://sanctix.ai');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
auditUrl = HOSTED_AUDIT_URL;
|
|
50
|
+
apiKey = 'sanctix-' + String(options.invite).trim().toLowerCase();
|
|
51
|
+
hosted = true;
|
|
52
|
+
console.log('Hosted mode: connecting to ' + HOSTED_AUDIT_URL);
|
|
53
|
+
console.log('API key generated from invite code.');
|
|
54
|
+
} else if (options.auditUrl) {
|
|
55
|
+
auditUrl = options.auditUrl;
|
|
56
|
+
apiKey = null;
|
|
57
|
+
console.log('Self-hosted mode: connecting to ' + auditUrl);
|
|
58
|
+
} else {
|
|
59
|
+
console.error(chalk.red('Specify --hosted --invite <code> or --audit-url <url>'));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Step 2: Detect runtime.
|
|
64
|
+
let runtime = await detectRuntime(cwd);
|
|
65
|
+
if (!runtime) {
|
|
66
|
+
const answer = await inquirer.prompt([{
|
|
67
|
+
type: 'list',
|
|
68
|
+
name: 'runtime',
|
|
69
|
+
message: 'Which runtime are you using?',
|
|
70
|
+
choices: ['claude_code', 'codex', 'cursor'],
|
|
71
|
+
}]);
|
|
72
|
+
runtime = answer.runtime;
|
|
73
|
+
}
|
|
74
|
+
console.log('Runtime detected: ' + runtime);
|
|
75
|
+
|
|
76
|
+
// Step 3: Connectivity check.
|
|
77
|
+
const health = await httpRequestJson('GET', auditUrl.replace(/\/$/, '') + '/health', { timeoutMs: 3000, apiKey });
|
|
78
|
+
if (health.status !== 200) {
|
|
79
|
+
console.log(chalk.yellow(
|
|
80
|
+
'Warning: audit service at ' + auditUrl + ' is not reachable. ' +
|
|
81
|
+
'Hooks will be installed but events will not land until the service is available.'));
|
|
82
|
+
if (!options.yes) {
|
|
83
|
+
const answer = await inquirer.prompt([{
|
|
84
|
+
type: 'confirm',
|
|
85
|
+
name: 'cont',
|
|
86
|
+
message: 'Continue anyway?',
|
|
87
|
+
default: false,
|
|
88
|
+
}]);
|
|
89
|
+
if (!answer.cont) {
|
|
90
|
+
console.log('Aborted.');
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
console.log('Audit service: connected');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Step 4: Install hook files.
|
|
99
|
+
await installHooks(runtime, cwd, HOOKS_SOURCE_DIR, { auditUrl });
|
|
100
|
+
|
|
101
|
+
// Step 5: Write .env entries.
|
|
102
|
+
await writeEnv(cwd, { auditUrl, apiKey });
|
|
103
|
+
|
|
104
|
+
// Step 6: Write manifest.
|
|
105
|
+
// Note: .sanctix/session.env is a managed path created by `sanctix start`
|
|
106
|
+
// and removed by `sanctix stop` / `sanctix uninstall`. It is not created
|
|
107
|
+
// here and is not added to managedFiles (its presence is opportunistic, so
|
|
108
|
+
// including it would corrupt the status hook missing-count check).
|
|
109
|
+
const manifest = {
|
|
110
|
+
version: '0.1.0',
|
|
111
|
+
runtime,
|
|
112
|
+
auditUrl,
|
|
113
|
+
hosted,
|
|
114
|
+
installedAt: new Date().toISOString(),
|
|
115
|
+
managedFiles: managedFilesFor(runtime),
|
|
116
|
+
managedEnvKeys: [
|
|
117
|
+
'SANCTIX_AUDIT_URL',
|
|
118
|
+
'SANCTIX_API_KEY',
|
|
119
|
+
'SANCTIX_HOOK_MODE',
|
|
120
|
+
],
|
|
121
|
+
managedSettingsEntries: managedSettingsEntriesFor(runtime),
|
|
122
|
+
};
|
|
123
|
+
await fs.ensureDir(path.join(cwd, '.sanctix'));
|
|
124
|
+
await fs.writeJSON(path.join(cwd, '.sanctix/sanctix.config.json'), manifest, { spaces: 2 });
|
|
125
|
+
|
|
126
|
+
// Step 7: Print confirmation.
|
|
127
|
+
console.log('');
|
|
128
|
+
console.log(chalk.green('[ok] Sanctix is now governing your ' + runtime + ' sessions.'));
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(' Audit endpoint: ' + auditUrl);
|
|
131
|
+
console.log(' Hook mode: enforce');
|
|
132
|
+
console.log(' Runtime: ' + runtime);
|
|
133
|
+
console.log('');
|
|
134
|
+
console.log(' Run sanctix status anytime to check governance health.');
|
|
135
|
+
console.log(' Run sanctix uninstall to remove Sanctix from this repo.');
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log(' Docs: https://sanctix.ai');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function managedFilesFor(runtime) {
|
|
141
|
+
if (runtime === 'claude_code') return CLAUDE_MANAGED_FILES;
|
|
142
|
+
if (runtime === 'cursor') return ['.cursor/rules/sanctix-governance.mdc'];
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function managedSettingsEntriesFor(runtime) {
|
|
147
|
+
if (runtime === 'claude_code') return { '.claude/settings.json': true };
|
|
148
|
+
if (runtime === 'cursor') return { '.cursor/hooks.json': true };
|
|
149
|
+
if (runtime === 'codex') return { 'AGENTS.md': true };
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function installHooks(runtime, cwd, sourceDir, opts) {
|
|
154
|
+
if (runtime === 'claude_code') return installClaudeHooks(cwd, sourceDir);
|
|
155
|
+
if (runtime === 'cursor') return installCursorHooks(cwd, sourceDir, opts);
|
|
156
|
+
if (runtime === 'codex') return installCodexHooks(cwd, opts);
|
|
157
|
+
throw new Error('Unsupported runtime: ' + runtime);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function installClaudeHooks(cwd, sourceDir) {
|
|
161
|
+
for (const [dir, files] of Object.entries(CLAUDE_HOOK_LAYOUT)) {
|
|
162
|
+
const target = path.join(cwd, '.claude/hooks', dir);
|
|
163
|
+
await fs.ensureDir(target);
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
await fs.copy(path.join(sourceDir, f), path.join(target, f), { overwrite: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Always copy shared helpers so hooks can require them.
|
|
169
|
+
for (const dir of Object.keys(CLAUDE_HOOK_LAYOUT)) {
|
|
170
|
+
const target = path.join(cwd, '.claude/hooks', dir);
|
|
171
|
+
await fs.copy(path.join(sourceDir, 'tool-capture.cjs'), path.join(target, 'tool-capture.cjs'), { overwrite: true });
|
|
172
|
+
await fs.copy(path.join(sourceDir, 'post-audit-event.cjs'), path.join(target, 'post-audit-event.cjs'), { overwrite: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const settingsPath = path.join(cwd, '.claude/settings.json');
|
|
176
|
+
const existing = (await fs.pathExists(settingsPath)) ? await fs.readJSON(settingsPath) : {};
|
|
177
|
+
existing.hooks = existing.hooks || {};
|
|
178
|
+
|
|
179
|
+
for (const [event, entries] of Object.entries(SANCTIX_HOOK_COMMANDS)) {
|
|
180
|
+
existing.hooks[event] = existing.hooks[event] || [];
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (hasHookEntry(existing.hooks[event], entry)) continue;
|
|
183
|
+
const block = { hooks: [{ type: 'command', command: entry.command }] };
|
|
184
|
+
if (entry.matcher !== null) block.matcher = entry.matcher;
|
|
185
|
+
existing.hooks[event].push(block);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await fs.writeJSON(settingsPath, existing, { spaces: 2 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasHookEntry(eventArray, entry) {
|
|
193
|
+
return eventArray.some((block) => {
|
|
194
|
+
const matcherMatches = entry.matcher === null
|
|
195
|
+
? block.matcher === undefined
|
|
196
|
+
: block.matcher === entry.matcher;
|
|
197
|
+
if (!matcherMatches) return false;
|
|
198
|
+
const hooks = block.hooks || [];
|
|
199
|
+
return hooks.some((h) => h && h.type === 'command' && h.command === entry.command);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function installCursorHooks(cwd, _sourceDir, { auditUrl }) {
|
|
204
|
+
const rulesDir = path.join(cwd, '.cursor/rules');
|
|
205
|
+
await fs.ensureDir(rulesDir);
|
|
206
|
+
const mdcPath = path.join(rulesDir, 'sanctix-governance.mdc');
|
|
207
|
+
const mdc = '---\nalwaysApply: true\n---\n# Sanctix Governance\nThis repository is governed by Sanctix.\nAudit endpoint: ' + auditUrl + '\nDo not bypass governance checks.\n';
|
|
208
|
+
await fs.writeFile(mdcPath, mdc);
|
|
209
|
+
|
|
210
|
+
const hooksJsonPath = path.join(cwd, '.cursor/hooks.json');
|
|
211
|
+
const existing = (await fs.pathExists(hooksJsonPath)) ? await fs.readJSON(hooksJsonPath) : {};
|
|
212
|
+
existing.sanctix = existing.sanctix || { managed: true };
|
|
213
|
+
await fs.writeJSON(hooksJsonPath, existing, { spaces: 2 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function installCodexHooks(cwd, { auditUrl }) {
|
|
217
|
+
const agentsPath = path.join(cwd, 'AGENTS.md');
|
|
218
|
+
let current = '';
|
|
219
|
+
if (await fs.pathExists(agentsPath)) current = await fs.readFile(agentsPath, 'utf8');
|
|
220
|
+
if (current.includes('## Sanctix Governance')) return;
|
|
221
|
+
const section =
|
|
222
|
+
(current.endsWith('\n') || current === '' ? '' : '\n') +
|
|
223
|
+
'\n## Sanctix Governance\n' +
|
|
224
|
+
'This repository is governed by Sanctix.\n' +
|
|
225
|
+
'Audit endpoint: ' + auditUrl + '\n' +
|
|
226
|
+
'Hook mode: enforce\n' +
|
|
227
|
+
'Do not bypass governance hooks.\n';
|
|
228
|
+
await fs.writeFile(agentsPath, current + section);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function writeEnv(cwd, { auditUrl, apiKey }) {
|
|
232
|
+
const envPath = path.join(cwd, '.env');
|
|
233
|
+
const desired = {
|
|
234
|
+
SANCTIX_AUDIT_URL: auditUrl,
|
|
235
|
+
SANCTIX_API_KEY: apiKey || '',
|
|
236
|
+
SANCTIX_HOOK_MODE: 'enforce',
|
|
237
|
+
AWF_TENANT_ID: '',
|
|
238
|
+
AWF_CORRELATION_ID: '',
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
let lines = [];
|
|
242
|
+
if (await fs.pathExists(envPath)) {
|
|
243
|
+
const text = await fs.readFile(envPath, 'utf8');
|
|
244
|
+
lines = text.split('\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
const result = [];
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
251
|
+
if (!m) { result.push(line); continue; }
|
|
252
|
+
const key = m[1];
|
|
253
|
+
const value = m[2];
|
|
254
|
+
if (!(key in desired)) {
|
|
255
|
+
result.push(line);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
seen.add(key);
|
|
259
|
+
// Sanctix-managed: overwrite only if empty or differs from desired non-empty value.
|
|
260
|
+
if (['SANCTIX_AUDIT_URL', 'SANCTIX_API_KEY', 'SANCTIX_HOOK_MODE'].includes(key)) {
|
|
261
|
+
result.push(`${key}=${desired[key]}`);
|
|
262
|
+
} else {
|
|
263
|
+
// AWF_TENANT_ID, AWF_CORRELATION_ID — never overwrite a user value.
|
|
264
|
+
result.push(value === '' ? `${key}=${desired[key]}` : line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [key, value] of Object.entries(desired)) {
|
|
268
|
+
if (seen.has(key)) continue;
|
|
269
|
+
result.push(`${key}=${value}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let out = result.join('\n');
|
|
273
|
+
if (!out.endsWith('\n')) out += '\n';
|
|
274
|
+
await fs.writeFile(envPath, out);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Re-export for sibling commands (uninstall/status).
|
|
278
|
+
export { HOOK_FILES, CLAUDE_MANAGED_FILES, SANCTIX_HOOK_COMMANDS };
|
package/cli/runtime.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import http from 'http';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
|
|
6
|
+
export const HOOK_FILES = [
|
|
7
|
+
'check-bash.cjs',
|
|
8
|
+
'check-file-edit.cjs',
|
|
9
|
+
'check-agent-spawn.cjs',
|
|
10
|
+
'check-agent-spawn-result.cjs',
|
|
11
|
+
'check-file-edit-result.cjs',
|
|
12
|
+
'check-subagent-start.cjs',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const CLAUDE_HOOK_LAYOUT = {
|
|
16
|
+
'pre-tool-use': [
|
|
17
|
+
'check-bash.cjs',
|
|
18
|
+
'check-file-edit.cjs',
|
|
19
|
+
'check-agent-spawn.cjs',
|
|
20
|
+
],
|
|
21
|
+
'post-tool-use': [
|
|
22
|
+
'check-agent-spawn-result.cjs',
|
|
23
|
+
'check-file-edit-result.cjs',
|
|
24
|
+
],
|
|
25
|
+
'sub-agent-start': [
|
|
26
|
+
'check-subagent-start.cjs',
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const CLAUDE_MANAGED_FILES = Object.entries(CLAUDE_HOOK_LAYOUT)
|
|
31
|
+
.flatMap(([dir, files]) => files.map((f) => `.claude/hooks/${dir}/${f}`));
|
|
32
|
+
|
|
33
|
+
export async function detectRuntime(cwd) {
|
|
34
|
+
const checks = [
|
|
35
|
+
{ runtime: 'claude_code', exists: await fs.pathExists(path.join(cwd, '.claude'))
|
|
36
|
+
|| await fs.pathExists(path.join(cwd, 'CLAUDE.md')) },
|
|
37
|
+
{ runtime: 'cursor', exists: await fs.pathExists(path.join(cwd, '.cursor')) },
|
|
38
|
+
{ runtime: 'codex', exists: await fs.pathExists(path.join(cwd, 'AGENTS.md')) },
|
|
39
|
+
];
|
|
40
|
+
for (const c of checks) {
|
|
41
|
+
if (c.exists) return c.runtime;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function httpRequestJson(method, urlString, { timeoutMs = 3000, apiKey = null, body = null } = {}) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = new URL(urlString);
|
|
50
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
51
|
+
const headers = {};
|
|
52
|
+
let data = null;
|
|
53
|
+
if (body) {
|
|
54
|
+
data = Buffer.from(JSON.stringify(body));
|
|
55
|
+
headers['content-type'] = 'application/json';
|
|
56
|
+
headers['content-length'] = data.length;
|
|
57
|
+
}
|
|
58
|
+
if (apiKey) headers['X-Sanctix-API-Key'] = apiKey;
|
|
59
|
+
const req = lib.request({
|
|
60
|
+
host: parsed.hostname,
|
|
61
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
62
|
+
path: parsed.pathname + (parsed.search || ''),
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
}, (res) => {
|
|
66
|
+
let chunks = '';
|
|
67
|
+
res.on('data', (d) => { chunks += d.toString(); });
|
|
68
|
+
res.on('end', () => {
|
|
69
|
+
let parsedBody = null;
|
|
70
|
+
try { parsedBody = chunks ? JSON.parse(chunks) : null; } catch (_e) { parsedBody = chunks; }
|
|
71
|
+
resolve({ status: res.statusCode, body: parsedBody });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
req.on('error', (err) => resolve({ status: 0, error: err.message }));
|
|
75
|
+
req.setTimeout(timeoutMs, () => {
|
|
76
|
+
try { req.destroy(); } catch (_e) {}
|
|
77
|
+
resolve({ status: 0, error: 'timeout' });
|
|
78
|
+
});
|
|
79
|
+
if (data) req.write(data);
|
|
80
|
+
req.end();
|
|
81
|
+
} catch (e) {
|
|
82
|
+
resolve({ status: 0, error: e.message });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
package/cli/start.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { httpRequestJson } from './runtime.js';
|
|
7
|
+
|
|
8
|
+
const HIGH_RISK_KEYWORDS = [
|
|
9
|
+
'auth', 'payment', 'stripe', 'database', 'migration', 'deploy',
|
|
10
|
+
'secret', 'credential', 'production', 'billing', 'webhook',
|
|
11
|
+
];
|
|
12
|
+
const LOW_RISK_KEYWORDS = [
|
|
13
|
+
'read', 'list', 'show', 'view', 'check', 'status', 'docs', 'documentation',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export async function start(taskDescription, options = {}) {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
|
|
19
|
+
// Step 1: Read manifest.
|
|
20
|
+
const manifestPath = path.join(cwd, '.sanctix/sanctix.config.json');
|
|
21
|
+
if (!(await fs.pathExists(manifestPath))) {
|
|
22
|
+
console.error(chalk.red('Sanctix is not initialized. Run sanctix init first.'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const manifest = await fs.readJSON(manifestPath);
|
|
26
|
+
|
|
27
|
+
// Step 2: Generate session identifiers.
|
|
28
|
+
const correlationId = crypto.randomUUID();
|
|
29
|
+
const sessionId = crypto.randomUUID();
|
|
30
|
+
const startedAt = new Date().toISOString();
|
|
31
|
+
const userId = process.env.USER || process.env.USERNAME || 'user';
|
|
32
|
+
|
|
33
|
+
// Step 3: Infer risk if not provided.
|
|
34
|
+
let risk;
|
|
35
|
+
const riskInferred = !options.risk;
|
|
36
|
+
if (options.risk) {
|
|
37
|
+
risk = String(options.risk).toLowerCase();
|
|
38
|
+
} else {
|
|
39
|
+
risk = inferRisk(taskDescription);
|
|
40
|
+
console.log('Risk level: ' + risk + ' (inferred from task description)');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Step 4: Post SANCTIX_SESSION_STARTED event.
|
|
44
|
+
const approvalType = risk === 'high' ? 'pending' : 'self';
|
|
45
|
+
const apiKey = process.env.SANCTIX_API_KEY || readEnvKey(cwd, 'SANCTIX_API_KEY');
|
|
46
|
+
const postRes = await httpRequestJson('POST', manifest.auditUrl.replace(/\/$/, '') + '/events', {
|
|
47
|
+
apiKey: apiKey || null,
|
|
48
|
+
timeoutMs: 3000,
|
|
49
|
+
body: {
|
|
50
|
+
actor_type: 'agent',
|
|
51
|
+
event_type: 'sanctix.session.started',
|
|
52
|
+
timestamp_utc: startedAt,
|
|
53
|
+
correlation_id: correlationId,
|
|
54
|
+
user_id: userId,
|
|
55
|
+
runtime_provider: manifest.runtime,
|
|
56
|
+
event_data: {
|
|
57
|
+
session_id: sessionId,
|
|
58
|
+
task_description: taskDescription,
|
|
59
|
+
risk_level: risk,
|
|
60
|
+
risk_inferred: riskInferred,
|
|
61
|
+
declared_by: userId,
|
|
62
|
+
declared_at: startedAt,
|
|
63
|
+
approved_by: userId,
|
|
64
|
+
approved_at: startedAt,
|
|
65
|
+
approval_type: approvalType,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (postRes.status !== 201 && postRes.status !== 200) {
|
|
70
|
+
console.warn(chalk.yellow(
|
|
71
|
+
'Warning: failed to post session.started event to ' + manifest.auditUrl +
|
|
72
|
+
(postRes.error ? ' (' + postRes.error + ')' : ' (HTTP ' + postRes.status + ')')
|
|
73
|
+
));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Step 5: For high risk, require explicit confirmation.
|
|
77
|
+
if (risk === 'high' && !options.yes) {
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(chalk.yellow('Risk level HIGH requires acknowledgment.'));
|
|
80
|
+
console.log('Task: ' + taskDescription);
|
|
81
|
+
console.log('This session will be fully audited.');
|
|
82
|
+
const answer = await inquirer.prompt([{
|
|
83
|
+
type: 'confirm',
|
|
84
|
+
name: 'cont',
|
|
85
|
+
message: 'Acknowledge and proceed?',
|
|
86
|
+
default: false,
|
|
87
|
+
}]);
|
|
88
|
+
if (!answer.cont) {
|
|
89
|
+
console.log('Aborted.');
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Step 6: Write session env file.
|
|
95
|
+
const truncatedTask = String(taskDescription).slice(0, 200);
|
|
96
|
+
const sessionEnvLines = [
|
|
97
|
+
'AWF_CORRELATION_ID=' + correlationId,
|
|
98
|
+
'AWF_SESSION_ID=' + sessionId,
|
|
99
|
+
'AWF_USER_ID=' + userId,
|
|
100
|
+
'SANCTIX_SESSION_STARTED=' + startedAt,
|
|
101
|
+
'SANCTIX_TASK=' + truncatedTask,
|
|
102
|
+
'SANCTIX_RISK=' + risk,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
await fs.ensureDir(path.join(cwd, '.sanctix'));
|
|
106
|
+
await fs.writeFile(path.join(cwd, '.sanctix/session.env'), sessionEnvLines.join('\n'));
|
|
107
|
+
|
|
108
|
+
// Step 7: Print activation instructions.
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(chalk.bold('=== Sanctix Session Started ==='));
|
|
111
|
+
console.log('Session: ' + correlationId);
|
|
112
|
+
console.log('Task: ' + taskDescription);
|
|
113
|
+
console.log('Risk: ' + risk);
|
|
114
|
+
console.log('Started: ' + startedAt);
|
|
115
|
+
console.log('');
|
|
116
|
+
console.log('Activate this session by running ONE of the following');
|
|
117
|
+
console.log('before launching your runtime:');
|
|
118
|
+
console.log('');
|
|
119
|
+
console.log('For bash/zsh (copy and run):');
|
|
120
|
+
console.log(' export AWF_CORRELATION_ID=' + correlationId);
|
|
121
|
+
console.log(' export AWF_USER_ID=' + userId);
|
|
122
|
+
if (apiKey) {
|
|
123
|
+
console.log(' export SANCTIX_API_KEY=' + apiKey);
|
|
124
|
+
}
|
|
125
|
+
console.log(' export SANCTIX_AUDIT_URL=' + manifest.auditUrl);
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log('Or source the session file:');
|
|
128
|
+
console.log(' source .sanctix/session.env && export $(cat .sanctix/session.env | cut -d= -f1)');
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log('Then launch your runtime normally:');
|
|
131
|
+
console.log(' claude --dangerously-skip-permissions (Claude Code)');
|
|
132
|
+
console.log(' cursor . (Cursor)');
|
|
133
|
+
console.log(' codex (Codex)');
|
|
134
|
+
console.log('');
|
|
135
|
+
console.log('When done, run: sanctix stop');
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log(chalk.bold('================================'));
|
|
138
|
+
|
|
139
|
+
// Step 8: Exit 0.
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function inferRisk(taskDescription) {
|
|
144
|
+
const text = String(taskDescription || '').toLowerCase();
|
|
145
|
+
for (const kw of HIGH_RISK_KEYWORDS) {
|
|
146
|
+
if (text.includes(kw)) return 'high';
|
|
147
|
+
}
|
|
148
|
+
for (const kw of LOW_RISK_KEYWORDS) {
|
|
149
|
+
if (text.includes(kw)) return 'low';
|
|
150
|
+
}
|
|
151
|
+
return 'medium';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readEnvKey(cwd, key) {
|
|
155
|
+
try {
|
|
156
|
+
const envPath = path.join(cwd, '.env');
|
|
157
|
+
if (!fs.pathExistsSync(envPath)) return null;
|
|
158
|
+
const text = fs.readFileSync(envPath, 'utf8');
|
|
159
|
+
for (const line of text.split('\n')) {
|
|
160
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
161
|
+
if (m && m[1] === key && m[2]) return m[2];
|
|
162
|
+
}
|
|
163
|
+
} catch (_e) {}
|
|
164
|
+
return null;
|
|
165
|
+
}
|