@sanctix/client 0.1.0 → 0.1.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/bin/sanctix.js +8 -0
- package/cli/init.js +47 -3
- package/cli/launch.js +133 -0
- package/cli/start.js +31 -1
- package/cli/stop.js +45 -27
- package/package.json +1 -1
package/bin/sanctix.js
CHANGED
|
@@ -5,6 +5,7 @@ import { uninstall } from '../cli/uninstall.js';
|
|
|
5
5
|
import { status } from '../cli/status.js';
|
|
6
6
|
import { start } from '../cli/start.js';
|
|
7
7
|
import { stop } from '../cli/stop.js';
|
|
8
|
+
import { launch } from '../cli/launch.js';
|
|
8
9
|
|
|
9
10
|
program
|
|
10
11
|
.name('sanctix')
|
|
@@ -39,6 +40,13 @@ program
|
|
|
39
40
|
.option('-y, --yes', 'Skip confirmation for high-risk tasks')
|
|
40
41
|
.action(start);
|
|
41
42
|
|
|
43
|
+
program
|
|
44
|
+
.command('launch <task> <runtime>')
|
|
45
|
+
.description('Start a governed session, launch a runtime and stop when it exits')
|
|
46
|
+
.option('--risk <level>', 'Risk level: low, medium, high')
|
|
47
|
+
.option('-y, --yes', 'Skip confirmation for high-risk tasks')
|
|
48
|
+
.action(launch);
|
|
49
|
+
|
|
42
50
|
program
|
|
43
51
|
.command('stop')
|
|
44
52
|
.description('Complete the active governed session')
|
package/cli/init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import url from 'url';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
4
5
|
import inquirer from 'inquirer';
|
|
5
6
|
import chalk from 'chalk';
|
|
6
7
|
import {
|
|
@@ -20,12 +21,12 @@ const HOSTED_AUDIT_URL = 'https://awf.ruvoni.com';
|
|
|
20
21
|
const SANCTIX_HOOK_COMMANDS = {
|
|
21
22
|
PreToolUse: [
|
|
22
23
|
{ matcher: 'Bash', command: 'node .claude/hooks/pre-tool-use/check-bash.cjs' },
|
|
23
|
-
{ matcher: '
|
|
24
|
+
{ matcher: 'Edit|Write', command: 'node .claude/hooks/pre-tool-use/check-file-edit.cjs' },
|
|
24
25
|
{ matcher: 'Agent', command: 'node .claude/hooks/pre-tool-use/check-agent-spawn.cjs' },
|
|
25
26
|
],
|
|
26
27
|
PostToolUse: [
|
|
27
28
|
{ matcher: 'Agent', command: 'node .claude/hooks/post-tool-use/check-agent-spawn-result.cjs' },
|
|
28
|
-
{ matcher: '
|
|
29
|
+
{ matcher: 'Edit|Write', command: 'node .claude/hooks/post-tool-use/check-file-edit-result.cjs' },
|
|
29
30
|
],
|
|
30
31
|
SubagentStart: [
|
|
31
32
|
{ matcher: null, command: 'node .claude/hooks/sub-agent-start/check-subagent-start.cjs' },
|
|
@@ -123,7 +124,17 @@ export async function init(options) {
|
|
|
123
124
|
await fs.ensureDir(path.join(cwd, '.sanctix'));
|
|
124
125
|
await fs.writeJSON(path.join(cwd, '.sanctix/sanctix.config.json'), manifest, { spaces: 2 });
|
|
125
126
|
|
|
126
|
-
// Step 7:
|
|
127
|
+
// Step 7: Discover agents from Claude Code permissions and assign stable UUIDs.
|
|
128
|
+
if (runtime === 'claude_code') {
|
|
129
|
+
const agents = await discoverAgents(cwd);
|
|
130
|
+
const names = Object.keys(agents);
|
|
131
|
+
if (names.length > 0) {
|
|
132
|
+
const parts = names.map((n) => `${n} (${agents[n]})`);
|
|
133
|
+
console.log('Discovered agents: ' + parts.join(', '));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Step 8: Print confirmation.
|
|
127
138
|
console.log('');
|
|
128
139
|
console.log(chalk.green('[ok] Sanctix is now governing your ' + runtime + ' sessions.'));
|
|
129
140
|
console.log('');
|
|
@@ -137,6 +148,39 @@ export async function init(options) {
|
|
|
137
148
|
console.log(' Docs: https://sanctix.ai');
|
|
138
149
|
}
|
|
139
150
|
|
|
151
|
+
async function discoverAgents(cwd) {
|
|
152
|
+
const settingsPath = path.join(cwd, '.claude/settings.json');
|
|
153
|
+
if (!(await fs.pathExists(settingsPath))) return {};
|
|
154
|
+
|
|
155
|
+
let settings;
|
|
156
|
+
try {
|
|
157
|
+
settings = await fs.readJSON(settingsPath);
|
|
158
|
+
} catch {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const allowList = (settings.permissions && settings.permissions.allow) || [];
|
|
163
|
+
const agentNames = allowList
|
|
164
|
+
.filter((p) => typeof p === 'string' && p.startsWith('Agent(') && p.endsWith(')'))
|
|
165
|
+
.map((p) => p.slice(6, -1).trim())
|
|
166
|
+
.filter((name) => name.length > 0);
|
|
167
|
+
|
|
168
|
+
if (agentNames.length === 0) return {};
|
|
169
|
+
|
|
170
|
+
const agentsPath = path.join(cwd, '.sanctix/agents.json');
|
|
171
|
+
const existing = (await fs.pathExists(agentsPath)) ? await fs.readJSON(agentsPath) : {};
|
|
172
|
+
|
|
173
|
+
const result = {};
|
|
174
|
+
for (const name of agentNames) {
|
|
175
|
+
result[name] = existing[name] || randomUUID();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await fs.ensureDir(path.join(cwd, '.sanctix'));
|
|
179
|
+
await fs.writeJSON(agentsPath, result, { spaces: 2 });
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
140
184
|
function managedFilesFor(runtime) {
|
|
141
185
|
if (runtime === 'claude_code') return CLAUDE_MANAGED_FILES;
|
|
142
186
|
if (runtime === 'cursor') return ['.cursor/rules/sanctix-governance.mdc'];
|
package/cli/launch.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { start } from './start.js';
|
|
6
|
+
import { stop } from './stop.js';
|
|
7
|
+
|
|
8
|
+
const RUNTIMES = {
|
|
9
|
+
claude_code: {
|
|
10
|
+
command: 'claude',
|
|
11
|
+
args: ['--dangerously-skip-permissions'],
|
|
12
|
+
label: 'Claude Code',
|
|
13
|
+
},
|
|
14
|
+
cursor: {
|
|
15
|
+
command: 'cursor',
|
|
16
|
+
args: ['.'],
|
|
17
|
+
label: 'Cursor',
|
|
18
|
+
},
|
|
19
|
+
codex: {
|
|
20
|
+
command: 'codex',
|
|
21
|
+
args: [],
|
|
22
|
+
label: 'Codex',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function launch(taskDescription, runtime, options = {}) {
|
|
27
|
+
const selectedRuntime = String(runtime || '').toLowerCase();
|
|
28
|
+
const spec = RUNTIMES[selectedRuntime];
|
|
29
|
+
|
|
30
|
+
if (!spec) {
|
|
31
|
+
console.error(chalk.red('Unknown runtime: ' + runtime));
|
|
32
|
+
console.error('Runtime must be one of: claude_code, cursor, codex');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let runtimeResult = { code: 1, signal: null };
|
|
37
|
+
let started = false;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const session = await start(taskDescription, {
|
|
41
|
+
...options,
|
|
42
|
+
runtime: selectedRuntime,
|
|
43
|
+
launch: true,
|
|
44
|
+
noExit: true,
|
|
45
|
+
});
|
|
46
|
+
started = true;
|
|
47
|
+
|
|
48
|
+
const sessionEnv = await readSessionEnv(process.cwd());
|
|
49
|
+
const childEnv = {
|
|
50
|
+
...process.env,
|
|
51
|
+
...sessionEnv,
|
|
52
|
+
AWF_CORRELATION_ID: session.correlationId,
|
|
53
|
+
AWF_SESSION_ID: session.sessionId,
|
|
54
|
+
AWF_USER_ID: session.userId,
|
|
55
|
+
SANCTIX_AUDIT_URL: session.auditUrl,
|
|
56
|
+
SANCTIX_RUNTIME: selectedRuntime,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (session.apiKey) {
|
|
60
|
+
childEnv.SANCTIX_API_KEY = session.apiKey;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log(chalk.bold('Launching ' + spec.label + ' with Sanctix session ' + session.correlationId));
|
|
65
|
+
runtimeResult = await runRuntime(spec, childEnv);
|
|
66
|
+
} finally {
|
|
67
|
+
if (started) {
|
|
68
|
+
await stop({ yes: true, noExit: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
exitWithRuntimeResult(runtimeResult);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runRuntime(spec, env) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(spec.command, spec.args, {
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
env,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.on('error', (err) => {
|
|
83
|
+
console.error(chalk.red('Failed to launch ' + spec.command + ': ' + err.message));
|
|
84
|
+
resolve({ code: 127, signal: null });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
child.on('exit', (code, signal) => {
|
|
88
|
+
resolve({ code: code == null ? 1 : code, signal });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readSessionEnv(cwd) {
|
|
94
|
+
const sessionEnvPath = path.join(cwd, '.sanctix/session.env');
|
|
95
|
+
if (!(await fs.pathExists(sessionEnvPath))) return {};
|
|
96
|
+
|
|
97
|
+
const text = await fs.readFile(sessionEnvPath, 'utf8');
|
|
98
|
+
const env = {};
|
|
99
|
+
for (const line of text.split('\n')) {
|
|
100
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
101
|
+
if (m) env[m[1]] = m[2];
|
|
102
|
+
}
|
|
103
|
+
return env;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function exitWithRuntimeResult(result) {
|
|
107
|
+
if (result.signal) {
|
|
108
|
+
const signalNumber = signalExitCode(result.signal);
|
|
109
|
+
process.exit(signalNumber || 1);
|
|
110
|
+
}
|
|
111
|
+
process.exit(result.code == null ? 1 : result.code);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function signalExitCode(signal) {
|
|
115
|
+
const signals = {
|
|
116
|
+
SIGHUP: 1,
|
|
117
|
+
SIGINT: 2,
|
|
118
|
+
SIGQUIT: 3,
|
|
119
|
+
SIGILL: 4,
|
|
120
|
+
SIGTRAP: 5,
|
|
121
|
+
SIGABRT: 6,
|
|
122
|
+
SIGBUS: 7,
|
|
123
|
+
SIGFPE: 8,
|
|
124
|
+
SIGKILL: 9,
|
|
125
|
+
SIGUSR1: 10,
|
|
126
|
+
SIGSEGV: 11,
|
|
127
|
+
SIGUSR2: 12,
|
|
128
|
+
SIGPIPE: 13,
|
|
129
|
+
SIGALRM: 14,
|
|
130
|
+
SIGTERM: 15,
|
|
131
|
+
};
|
|
132
|
+
return signals[signal] ? 128 + signals[signal] : null;
|
|
133
|
+
}
|
package/cli/start.js
CHANGED
|
@@ -29,6 +29,7 @@ export async function start(taskDescription, options = {}) {
|
|
|
29
29
|
const sessionId = crypto.randomUUID();
|
|
30
30
|
const startedAt = new Date().toISOString();
|
|
31
31
|
const userId = process.env.USER || process.env.USERNAME || 'user';
|
|
32
|
+
const runtimeProvider = options.runtime || manifest.runtime || 'unknown';
|
|
32
33
|
|
|
33
34
|
// Step 3: Infer risk if not provided.
|
|
34
35
|
let risk;
|
|
@@ -52,7 +53,7 @@ export async function start(taskDescription, options = {}) {
|
|
|
52
53
|
timestamp_utc: startedAt,
|
|
53
54
|
correlation_id: correlationId,
|
|
54
55
|
user_id: userId,
|
|
55
|
-
runtime_provider:
|
|
56
|
+
runtime_provider: runtimeProvider,
|
|
56
57
|
event_data: {
|
|
57
58
|
session_id: sessionId,
|
|
58
59
|
task_description: taskDescription,
|
|
@@ -100,6 +101,7 @@ export async function start(taskDescription, options = {}) {
|
|
|
100
101
|
'SANCTIX_SESSION_STARTED=' + startedAt,
|
|
101
102
|
'SANCTIX_TASK=' + truncatedTask,
|
|
102
103
|
'SANCTIX_RISK=' + risk,
|
|
104
|
+
'SANCTIX_RUNTIME=' + runtimeProvider,
|
|
103
105
|
'',
|
|
104
106
|
];
|
|
105
107
|
await fs.ensureDir(path.join(cwd, '.sanctix'));
|
|
@@ -112,6 +114,21 @@ export async function start(taskDescription, options = {}) {
|
|
|
112
114
|
console.log('Task: ' + taskDescription);
|
|
113
115
|
console.log('Risk: ' + risk);
|
|
114
116
|
console.log('Started: ' + startedAt);
|
|
117
|
+
if (options.launch) {
|
|
118
|
+
console.log('Runtime: ' + runtimeProvider);
|
|
119
|
+
console.log(chalk.bold('================================'));
|
|
120
|
+
return {
|
|
121
|
+
correlationId,
|
|
122
|
+
sessionId,
|
|
123
|
+
userId,
|
|
124
|
+
startedAt,
|
|
125
|
+
task: taskDescription,
|
|
126
|
+
risk,
|
|
127
|
+
auditUrl: manifest.auditUrl,
|
|
128
|
+
apiKey,
|
|
129
|
+
runtime: runtimeProvider,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
115
132
|
console.log('');
|
|
116
133
|
console.log('Activate this session by running ONE of the following');
|
|
117
134
|
console.log('before launching your runtime:');
|
|
@@ -137,6 +154,19 @@ export async function start(taskDescription, options = {}) {
|
|
|
137
154
|
console.log(chalk.bold('================================'));
|
|
138
155
|
|
|
139
156
|
// Step 8: Exit 0.
|
|
157
|
+
if (options.noExit) {
|
|
158
|
+
return {
|
|
159
|
+
correlationId,
|
|
160
|
+
sessionId,
|
|
161
|
+
userId,
|
|
162
|
+
startedAt,
|
|
163
|
+
task: taskDescription,
|
|
164
|
+
risk,
|
|
165
|
+
auditUrl: manifest.auditUrl,
|
|
166
|
+
apiKey,
|
|
167
|
+
runtime: runtimeProvider,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
140
170
|
process.exit(0);
|
|
141
171
|
}
|
|
142
172
|
|
package/cli/stop.js
CHANGED
|
@@ -10,6 +10,7 @@ export async function stop(_options = {}) {
|
|
|
10
10
|
const sessionEnvPath = path.join(cwd, '.sanctix/session.env');
|
|
11
11
|
if (!(await fs.pathExists(sessionEnvPath))) {
|
|
12
12
|
console.log('No active Sanctix session found.');
|
|
13
|
+
if (_options.noExit) return;
|
|
13
14
|
process.exit(0);
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -26,28 +27,14 @@ export async function stop(_options = {}) {
|
|
|
26
27
|
const manifestPath = path.join(cwd, '.sanctix/sanctix.config.json');
|
|
27
28
|
if (!(await fs.pathExists(manifestPath))) {
|
|
28
29
|
console.error(chalk.red('Sanctix manifest missing. Cannot complete session cleanly.'));
|
|
30
|
+
if (_options.noExit) return;
|
|
29
31
|
process.exit(1);
|
|
30
32
|
}
|
|
31
33
|
const manifest = await fs.readJSON(manifestPath);
|
|
32
34
|
const auditUrl = manifest.auditUrl.replace(/\/$/, '');
|
|
33
35
|
const apiKey = process.env.SANCTIX_API_KEY || readEnvKey(cwd, 'SANCTIX_API_KEY');
|
|
34
36
|
|
|
35
|
-
// Step 4:
|
|
36
|
-
const sessionRes = await httpRequestJson('GET', auditUrl + '/session/' + correlationId, {
|
|
37
|
-
apiKey: apiKey || null,
|
|
38
|
-
timeoutMs: 3000,
|
|
39
|
-
});
|
|
40
|
-
let summaryLine = null;
|
|
41
|
-
if (sessionRes.status === 200 && sessionRes.body && typeof sessionRes.body === 'object') {
|
|
42
|
-
const count = sessionRes.body.event_count;
|
|
43
|
-
const chain = sessionRes.body.chain_status;
|
|
44
|
-
summaryLine = 'Events: ' + (count !== undefined ? count : 'n/a') +
|
|
45
|
-
(chain ? ' (chain: ' + chain + ')' : '');
|
|
46
|
-
} else {
|
|
47
|
-
summaryLine = 'Session data not yet available.';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Step 5: Post SANCTIX_SESSION_COMPLETED event.
|
|
37
|
+
// Step 4: Post sanctix.session.completed event.
|
|
51
38
|
const completedAt = new Date().toISOString();
|
|
52
39
|
const userId = process.env.USER || process.env.USERNAME || 'user';
|
|
53
40
|
const postRes = await httpRequestJson('POST', auditUrl + '/events', {
|
|
@@ -76,23 +63,54 @@ export async function stop(_options = {}) {
|
|
|
76
63
|
));
|
|
77
64
|
}
|
|
78
65
|
|
|
66
|
+
// Step 5: Fetch session summary from audit service.
|
|
67
|
+
const sessionRes = await httpRequestJson('GET', auditUrl + '/session/' + correlationId, {
|
|
68
|
+
apiKey: apiKey || null,
|
|
69
|
+
timeoutMs: 3000,
|
|
70
|
+
});
|
|
71
|
+
|
|
79
72
|
// Step 6: Remove session env file.
|
|
80
73
|
await fs.remove(sessionEnvPath);
|
|
81
74
|
|
|
82
75
|
// Step 7: Print summary.
|
|
83
76
|
const duration = formatDuration(startedAt, completedAt);
|
|
84
77
|
console.log('');
|
|
85
|
-
console.log(chalk.bold('=== Sanctix Session
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
console.log(chalk.bold('=== Sanctix Session Summary ==='));
|
|
79
|
+
if (sessionRes.status === 200 && sessionRes.body && typeof sessionRes.body === 'object') {
|
|
80
|
+
const s = sessionRes.body;
|
|
81
|
+
const toolBreakdown = s.tool_breakdown || {};
|
|
82
|
+
const toolCalls = Object.values(toolBreakdown).reduce((a, b) => a + (Number(b) || 0), 0);
|
|
83
|
+
const filesChanged = Array.isArray(s.files_changed) ? s.files_changed : [];
|
|
84
|
+
const commands = Array.isArray(s.commands) ? s.commands : [];
|
|
85
|
+
|
|
86
|
+
console.log('Task: ' + task);
|
|
87
|
+
console.log('Risk: ' + risk);
|
|
88
|
+
console.log('Duration: ' + duration);
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log('What happened:');
|
|
91
|
+
console.log(' Tool calls: ' + toolCalls);
|
|
92
|
+
console.log(' Files changed: ' + filesChanged.length);
|
|
93
|
+
console.log(' Commands run: ' + (s.commands_run || 0));
|
|
94
|
+
console.log(' Agent spawns: ' + (s.agent_spawns || 0));
|
|
95
|
+
if (commands.length > 0) {
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log('Commands run:');
|
|
98
|
+
for (const c of commands) console.log(' ' + c);
|
|
99
|
+
}
|
|
100
|
+
if (filesChanged.length > 0) {
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log('Files touched:');
|
|
103
|
+
for (const f of filesChanged) console.log(' ' + f);
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log('Audit:');
|
|
107
|
+
console.log(' Events: ' + (s.event_count != null ? s.event_count : 'n/a'));
|
|
108
|
+
console.log(' Chain: ' + (s.chain_status || 'unknown'));
|
|
109
|
+
console.log(' Session: ' + correlationId);
|
|
110
|
+
} else {
|
|
111
|
+
console.log('Session: ' + correlationId);
|
|
112
|
+
}
|
|
113
|
+
console.log('================================');
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
function parseEnvFile(text) {
|
package/package.json
CHANGED