habi-agent 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/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +295 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud.d.ts +50 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +147 -0
- package/dist/cloud.js.map +1 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +172 -0
- package/dist/context.js.map +1 -0
- package/dist/hardware.d.ts +38 -0
- package/dist/hardware.d.ts.map +1 -0
- package/dist/hardware.js +130 -0
- package/dist/hardware.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +276 -0
- package/dist/server.js.map +1 -0
- package/package.json +24 -0
- package/src/cli.ts +264 -0
- package/src/cloud.ts +165 -0
- package/src/context.ts +186 -0
- package/src/hardware.ts +114 -0
- package/src/index.ts +5 -0
- package/src/server.ts +255 -0
- package/tsconfig.json +10 -0
package/src/cloud.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HABI Cloud Sync
|
|
3
|
+
* Authenticates via HABI_KEY, registers device, and sends heartbeats.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as https from 'https';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
|
|
9
|
+
const HABI_API = process.env.HABI_API_URL ||
|
|
10
|
+
'https://dokcixjitwihtbdlkbdb.supabase.co/functions/v1';
|
|
11
|
+
|
|
12
|
+
const HABI_KEY = process.env.HABI_KEY || '';
|
|
13
|
+
const OWNER_EMAIL = process.env.HABI_OWNER_EMAIL || '';
|
|
14
|
+
|
|
15
|
+
interface RegisterResponse {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
deviceId?: string;
|
|
18
|
+
ownerId?: string;
|
|
19
|
+
sessionToken?: string;
|
|
20
|
+
sessionTokenExpiresAt?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface HeartbeatResponse {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
sessionToken?: string;
|
|
27
|
+
sessionTokenExpiresAt?: string;
|
|
28
|
+
tokenRotated?: boolean;
|
|
29
|
+
pendingJobs?: Array<{ id: string; payload: Record<string, unknown>; payload_hash: string }>;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface JobResultPayload {
|
|
34
|
+
jobId: string;
|
|
35
|
+
deviceId: string;
|
|
36
|
+
sessionToken: string;
|
|
37
|
+
status: 'COMPLETED' | 'FAILED';
|
|
38
|
+
result?: Record<string, unknown>;
|
|
39
|
+
error?: string;
|
|
40
|
+
durationMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function post<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const bodyStr = JSON.stringify(body);
|
|
46
|
+
const url = new URL(`${HABI_API}/${path}`);
|
|
47
|
+
|
|
48
|
+
const headers: Record<string, string> = {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'Content-Length': String(Buffer.byteLength(bodyStr)),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Pass HABI_KEY in header if set
|
|
54
|
+
if (HABI_KEY) {
|
|
55
|
+
headers['x-habi-key'] = HABI_KEY;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const options: https.RequestOptions = {
|
|
59
|
+
hostname: url.hostname,
|
|
60
|
+
port: 443,
|
|
61
|
+
path: url.pathname,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const req = https.request(options, (res) => {
|
|
67
|
+
let data = '';
|
|
68
|
+
res.on('data', (chunk: Buffer) => data += chunk.toString());
|
|
69
|
+
res.on('end', () => {
|
|
70
|
+
try { resolve(JSON.parse(data) as T); }
|
|
71
|
+
catch { reject(new Error(`Parse error: ${data.slice(0, 100)}`)); }
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
req.on('error', reject);
|
|
76
|
+
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Cloud sync timeout')); });
|
|
77
|
+
req.write(bodyStr);
|
|
78
|
+
req.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CloudState {
|
|
83
|
+
deviceId: string;
|
|
84
|
+
ownerId: string;
|
|
85
|
+
sessionToken: string;
|
|
86
|
+
sessionTokenExpiresAt: string;
|
|
87
|
+
registered: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function registerDevice(params: {
|
|
91
|
+
friendlyName: string;
|
|
92
|
+
hardwareFingerprint: string;
|
|
93
|
+
fingerprintType: 'mac' | 'ipv6_link_local';
|
|
94
|
+
agentVersion?: string;
|
|
95
|
+
osPlatform?: string;
|
|
96
|
+
}): Promise<CloudState | null> {
|
|
97
|
+
|
|
98
|
+
if (!HABI_KEY && !OWNER_EMAIL) {
|
|
99
|
+
console.warn('[HABI] No HABI_KEY or HABI_OWNER_EMAIL set — running offline.');
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const body: Record<string, unknown> = { ...params };
|
|
105
|
+
if (HABI_KEY) body.habiKey = HABI_KEY;
|
|
106
|
+
if (OWNER_EMAIL) body.ownerEmail = OWNER_EMAIL;
|
|
107
|
+
|
|
108
|
+
const res = await post<RegisterResponse>('device-register', body);
|
|
109
|
+
|
|
110
|
+
if (!res.ok || !res.deviceId) {
|
|
111
|
+
console.error(`[HABI] Registration failed: ${res.error}`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(`[HABI] ✓ Device registered: ${params.friendlyName} (${res.deviceId.slice(0, 8)}...)`);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
deviceId: res.deviceId,
|
|
119
|
+
ownerId: res.ownerId!,
|
|
120
|
+
sessionToken: res.sessionToken!,
|
|
121
|
+
sessionTokenExpiresAt: res.sessionTokenExpiresAt!,
|
|
122
|
+
registered: true,
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`[HABI] Registration error: ${err}`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function sendHeartbeat(params: {
|
|
131
|
+
deviceId: string;
|
|
132
|
+
sessionToken: string;
|
|
133
|
+
hardwareFingerprint: string;
|
|
134
|
+
}): Promise<{ sessionToken: string; pendingJobs: HeartbeatResponse['pendingJobs'] } | null> {
|
|
135
|
+
try {
|
|
136
|
+
const body: Record<string, unknown> = { ...params };
|
|
137
|
+
if (HABI_KEY) body.habiKey = HABI_KEY;
|
|
138
|
+
|
|
139
|
+
const res = await post<HeartbeatResponse>('heartbeat', body);
|
|
140
|
+
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
console.error(`[HABI] Heartbeat failed: ${res.error}`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
sessionToken: res.sessionToken || params.sessionToken,
|
|
148
|
+
pendingJobs: res.pendingJobs || [],
|
|
149
|
+
};
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(`[HABI] Heartbeat error: ${err}`);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function reportJobResult(payload: JobResultPayload): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
const body = { ...payload as unknown as Record<string, unknown> };
|
|
159
|
+
if (HABI_KEY) body.habiKey = HABI_KEY;
|
|
160
|
+
await post<{ ok: boolean }>('job-result', body);
|
|
161
|
+
console.log(`[HABI] Job result reported: ${payload.jobId} → ${payload.status}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`[HABI] Job result error: ${err}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HABI Project Context File Loader
|
|
3
|
+
*
|
|
4
|
+
* The Project Context File is the source of truth for what is authorized
|
|
5
|
+
* on this machine for this project. It lives OUTSIDE version control.
|
|
6
|
+
*
|
|
7
|
+
* The SHA-256 hash of the file is computed on load and included in every
|
|
8
|
+
* session fingerprint. Tampering with the file invalidates active sessions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
export interface AllowedOperations {
|
|
16
|
+
paths?: string[];
|
|
17
|
+
cloudAccounts?: string[];
|
|
18
|
+
databases?: string[];
|
|
19
|
+
repos?: string[];
|
|
20
|
+
commands?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProjectContext {
|
|
24
|
+
project: string;
|
|
25
|
+
version: string;
|
|
26
|
+
owner: string;
|
|
27
|
+
allowedMachines: string[]; // MAC or IPv6 link-local values
|
|
28
|
+
allowedOperations: AllowedOperations;
|
|
29
|
+
deniedOperations: string[]; // string patterns — rejected on match
|
|
30
|
+
contextHash: string; // SHA-256 of file contents (self-referential, set on write)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LoadedContext {
|
|
34
|
+
context: ProjectContext;
|
|
35
|
+
contextHash: string; // recomputed on load — verified against stored
|
|
36
|
+
filePath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONTEXT_FILE_NAMES = [
|
|
40
|
+
'habi-context.json',
|
|
41
|
+
'.habi-context.json',
|
|
42
|
+
'habi.context.json',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Locates the context file by walking up from cwd.
|
|
47
|
+
* Searches: cwd → parent → grandparent → $HOME
|
|
48
|
+
*/
|
|
49
|
+
export function findContextFile(startDir: string = process.cwd()): string | null {
|
|
50
|
+
let dir = path.resolve(startDir);
|
|
51
|
+
const home = os_homedir();
|
|
52
|
+
|
|
53
|
+
while (true) {
|
|
54
|
+
for (const name of CONTEXT_FILE_NAMES) {
|
|
55
|
+
const candidate = path.join(dir, name);
|
|
56
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir) break; // filesystem root
|
|
60
|
+
if (dir === home) break; // don't go above home
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function os_homedir(): string {
|
|
67
|
+
return process.env.HOME || process.env.USERPROFILE || '/';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Loads and validates a Project Context File.
|
|
72
|
+
*
|
|
73
|
+
* Validation:
|
|
74
|
+
* 1. File exists and is valid JSON
|
|
75
|
+
* 2. Required fields are present
|
|
76
|
+
* 3. Recomputed hash matches stored contextHash
|
|
77
|
+
* (if mismatch → file was tampered with → reject)
|
|
78
|
+
*/
|
|
79
|
+
export function loadContext(filePath: string): LoadedContext {
|
|
80
|
+
if (!fs.existsSync(filePath)) {
|
|
81
|
+
throw new Error(`HABI: Context file not found: ${filePath}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
85
|
+
|
|
86
|
+
let context: ProjectContext;
|
|
87
|
+
try {
|
|
88
|
+
context = JSON.parse(raw) as ProjectContext;
|
|
89
|
+
} catch {
|
|
90
|
+
throw new Error(`HABI: Context file is not valid JSON: ${filePath}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate required fields
|
|
94
|
+
const required = ['project', 'version', 'owner', 'allowedMachines', 'allowedOperations', 'deniedOperations'];
|
|
95
|
+
for (const field of required) {
|
|
96
|
+
if (!(field in context)) {
|
|
97
|
+
throw new Error(`HABI: Context file missing required field: ${field}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Compute hash of file contents excluding the contextHash field itself
|
|
102
|
+
const { contextHash: _stored, ...rest } = context;
|
|
103
|
+
const computedHash = crypto
|
|
104
|
+
.createHash('sha256')
|
|
105
|
+
.update(JSON.stringify(rest, null, 2))
|
|
106
|
+
.digest('hex');
|
|
107
|
+
|
|
108
|
+
// If contextHash is set, verify it matches
|
|
109
|
+
if (_stored && _stored !== computedHash) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`HABI: Context file integrity check FAILED.\n` +
|
|
112
|
+
` Stored hash: ${_stored}\n` +
|
|
113
|
+
` Computed hash: ${computedHash}\n` +
|
|
114
|
+
` File may have been tampered with: ${filePath}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
context,
|
|
120
|
+
contextHash: computedHash,
|
|
121
|
+
filePath,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Checks whether a proposed operation is authorized under the loaded context.
|
|
127
|
+
*
|
|
128
|
+
* Returns: { allowed: true } or { allowed: false, reason: string }
|
|
129
|
+
*/
|
|
130
|
+
export function assertOperation(
|
|
131
|
+
operation: string,
|
|
132
|
+
context: ProjectContext
|
|
133
|
+
): { allowed: boolean; reason?: string } {
|
|
134
|
+
|
|
135
|
+
const opLower = operation.toLowerCase();
|
|
136
|
+
|
|
137
|
+
// ── Check denied list first (deny wins) ────────────────────────────────────
|
|
138
|
+
for (const denied of context.deniedOperations) {
|
|
139
|
+
if (opLower.includes(denied.toLowerCase())) {
|
|
140
|
+
return {
|
|
141
|
+
allowed: false,
|
|
142
|
+
reason: `Operation matches denied pattern: "${denied}"`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Check allowed paths ─────────────────────────────────────────────────────
|
|
148
|
+
const ops = context.allowedOperations;
|
|
149
|
+
|
|
150
|
+
if (ops.commands && ops.commands.length > 0) {
|
|
151
|
+
const commandAllowed = ops.commands.some(cmd =>
|
|
152
|
+
opLower.startsWith(cmd.toLowerCase())
|
|
153
|
+
);
|
|
154
|
+
if (!commandAllowed) {
|
|
155
|
+
return {
|
|
156
|
+
allowed: false,
|
|
157
|
+
reason: `Operation not in allowedOperations.commands list`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { allowed: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Checks whether this machine's hardware ID is authorized in the context file.
|
|
167
|
+
*/
|
|
168
|
+
export function assertMachineAuthorized(
|
|
169
|
+
hardwareId: string,
|
|
170
|
+
context: ProjectContext
|
|
171
|
+
): { allowed: boolean; reason?: string } {
|
|
172
|
+
const allowed = context.allowedMachines.some(
|
|
173
|
+
m => m.toUpperCase() === hardwareId.toUpperCase()
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!allowed) {
|
|
177
|
+
return {
|
|
178
|
+
allowed: false,
|
|
179
|
+
reason:
|
|
180
|
+
`Hardware ID ${hardwareId} is not in allowedMachines list. ` +
|
|
181
|
+
`Authorized machines: ${context.allowedMachines.join(', ')}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { allowed: true };
|
|
186
|
+
}
|
package/src/hardware.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HABI Hardware Identity Reader
|
|
3
|
+
* Reads MAC address (physical) or IPv6 link-local (container/VM)
|
|
4
|
+
* This is the foundation primitive of the entire HABI system.
|
|
5
|
+
*
|
|
6
|
+
* On physical machines: returns MAC via os.networkInterfaces()
|
|
7
|
+
* On containers/VMs: returns IPv6 link-local (fe80::/10) — auto-generated
|
|
8
|
+
* per virtual NIC, stable for container lifetime,
|
|
9
|
+
* non-routable externally
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import * as crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
export type FingerprintType = 'mac' | 'ipv6_link_local';
|
|
16
|
+
|
|
17
|
+
export interface HardwareIdentity {
|
|
18
|
+
fingerprint: string;
|
|
19
|
+
type: FingerprintType;
|
|
20
|
+
iface: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
hostname: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads network interfaces and returns the primary hardware identifier.
|
|
27
|
+
* Priority: real MAC → IPv6 link-local → error
|
|
28
|
+
*
|
|
29
|
+
* Excludes loopback and virtual/docker interfaces.
|
|
30
|
+
* Returns the most stable, unique identifier available on this machine.
|
|
31
|
+
*/
|
|
32
|
+
export function readHardwareIdentity(): HardwareIdentity {
|
|
33
|
+
const ifaces = os.networkInterfaces();
|
|
34
|
+
const platform = os.platform();
|
|
35
|
+
const hostname = os.hostname();
|
|
36
|
+
|
|
37
|
+
// ── Pass 1: Find a real MAC address ────────────────────────────────────────
|
|
38
|
+
// Exclude loopback (lo, lo0) and known virtual prefixes
|
|
39
|
+
const VIRTUAL_PREFIXES = ['docker', 'br-', 'veth', 'virbr', 'vmnet', 'vbox'];
|
|
40
|
+
|
|
41
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
42
|
+
if (!addrs) continue;
|
|
43
|
+
|
|
44
|
+
// Skip loopback
|
|
45
|
+
if (addrs.some(a => a.internal)) continue;
|
|
46
|
+
|
|
47
|
+
// Skip known virtual interfaces
|
|
48
|
+
const ifaceLower = name.toLowerCase();
|
|
49
|
+
if (VIRTUAL_PREFIXES.some(prefix => ifaceLower.startsWith(prefix))) continue;
|
|
50
|
+
|
|
51
|
+
for (const addr of addrs) {
|
|
52
|
+
const mac = addr.mac;
|
|
53
|
+
// Validate: real MAC is not all-zeros, not broadcast
|
|
54
|
+
if (mac && mac !== '00:00:00:00:00:00' && mac !== 'ff:ff:ff:ff:ff:ff') {
|
|
55
|
+
return {
|
|
56
|
+
fingerprint: mac.toUpperCase(),
|
|
57
|
+
type: 'mac',
|
|
58
|
+
iface: name,
|
|
59
|
+
platform,
|
|
60
|
+
hostname,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Pass 2: IPv6 link-local (fe80::/10) — container/VM fallback ───────────
|
|
67
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
68
|
+
if (!addrs) continue;
|
|
69
|
+
if (addrs.some(a => a.internal)) continue;
|
|
70
|
+
|
|
71
|
+
for (const addr of addrs) {
|
|
72
|
+
if (addr.family === 'IPv6' && addr.address.toLowerCase().startsWith('fe80')) {
|
|
73
|
+
return {
|
|
74
|
+
fingerprint: addr.address.toLowerCase(),
|
|
75
|
+
type: 'ipv6_link_local',
|
|
76
|
+
iface: name,
|
|
77
|
+
platform,
|
|
78
|
+
hostname,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Fail loudly — never silently return a fake identity ───────────────────
|
|
85
|
+
throw new Error(
|
|
86
|
+
'HABI: No stable hardware identifier found. ' +
|
|
87
|
+
'Physical machines require a non-virtual network interface with a MAC address. ' +
|
|
88
|
+
'Containers require an IPv6 link-local address (fe80::/10).'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generates a session fingerprint from hardware identity + context hash + timestamp.
|
|
94
|
+
* This fingerprint is signed into every audit log entry.
|
|
95
|
+
*
|
|
96
|
+
* fingerprint = SHA-256(hardware_id + ":" + context_hash + ":" + timestamp_ms)
|
|
97
|
+
*/
|
|
98
|
+
export function generateSessionFingerprint(
|
|
99
|
+
hardwareId: string,
|
|
100
|
+
contextHash: string,
|
|
101
|
+
timestampMs: number = Date.now()
|
|
102
|
+
): string {
|
|
103
|
+
return crypto
|
|
104
|
+
.createHash('sha256')
|
|
105
|
+
.update(`${hardwareId}:${contextHash}:${timestampMs}`)
|
|
106
|
+
.digest('hex');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Computes SHA-256 of arbitrary input — used for operation hashing in audit log.
|
|
111
|
+
*/
|
|
112
|
+
export function sha256(input: string): string {
|
|
113
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { HabiLocalServer } from './server';
|
|
2
|
+
export { readHardwareIdentity, generateSessionFingerprint, sha256 } from './hardware';
|
|
3
|
+
export { findContextFile, loadContext, assertOperation, assertMachineAuthorized } from './context';
|
|
4
|
+
export type { HardwareIdentity, FingerprintType } from './hardware';
|
|
5
|
+
export type { ProjectContext, LoadedContext, AllowedOperations } from './context';
|