@agentpulselabs/cli-plugin-pulse 0.1.0 → 1.0.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/README.md +69 -32
- package/package.json +10 -10
- package/src/commands/pulse/doctor.js +14 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/start.js +8 -18
- package/src/commands/pulse/status.js +8 -7
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +36 -0
- package/src/lib/BaseCommand.js +37 -0
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +656 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +55 -0
- package/src/lib/daemonLoop.js +69 -0
- package/src/lib/daemonProcess.js +366 -0
- package/src/lib/daemonRuntime.js +820 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +19 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +358 -0
- package/src/lib/mirrorSource.js +455 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +358 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +97 -0
- package/src/lib/transport.js +286 -0
- package/src/lib/transportContract.js +37 -0
- package/src/commands/pulse/gate.js +0 -23
- package/src/commands/pulse/next.js +0 -13
- package/src/commands/pulse/submit.js +0 -19
- package/src/commands/pulse/verify.js +0 -18
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CREDENTIAL_REPLACEMENT, SECRET_RULES, WITHHELD_MARKER, redactSecretBlocks, redactSecretLines, secretKeyReplacement, secretLeafReplacement,
|
|
3
|
+
secretSubtreeFamily, v1GateFires,
|
|
4
|
+
} from './mirrorContract.js';
|
|
5
|
+
|
|
6
|
+
function truncateUtf8(value, maxBytes) {
|
|
7
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
8
|
+
if (bytes.length <= maxBytes) return { text: value, truncated: false };
|
|
9
|
+
if (maxBytes <= 0) return { text: '', truncated: true };
|
|
10
|
+
return {
|
|
11
|
+
text: bytes.subarray(0, maxBytes).toString('utf8').replace(/\uFFFD$/, ''),
|
|
12
|
+
truncated: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The contract's passes over one string, without the v1 gate: the block pass, the secret table, the line net, then each
|
|
18
|
+
* held credential by identity. Every upload path goes through redactSecrets, which adds the gate.
|
|
19
|
+
*/
|
|
20
|
+
export function redactSecretPasses(input, { secrets = [] } = {}) {
|
|
21
|
+
const blocks = redactSecretBlocks(String(input ?? ''));
|
|
22
|
+
let { text, redactions } = blocks;
|
|
23
|
+
for (const rule of SECRET_RULES) {
|
|
24
|
+
if (!rule.probe.test(text)) continue;
|
|
25
|
+
text = text.replace(rule.pattern, (...match) => {
|
|
26
|
+
redactions += 1;
|
|
27
|
+
const groups = typeof match.at(-1) === 'object' ? match.at(-1) : {};
|
|
28
|
+
return `${groups.keep ?? ''}${rule.replacement}${groups.tail ?? ''}`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const lines = redactSecretLines(text);
|
|
32
|
+
text = lines.text;
|
|
33
|
+
redactions += lines.redactions;
|
|
34
|
+
for (const secret of secrets) {
|
|
35
|
+
if (!secret || !text.includes(secret)) continue;
|
|
36
|
+
redactions += text.split(secret).length - 1;
|
|
37
|
+
text = text.split(secret).join(CREDENTIAL_REPLACEMENT);
|
|
38
|
+
}
|
|
39
|
+
return blocks.withheld ? { text, redactions, withheld: true } : { text, redactions };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The contract's passes over one string (redactSecretPasses), then its v1 gate. Returns the text, how many matches were
|
|
44
|
+
* replaced, and `withheld` (only when true) when a block had no end or v1's pattern still matches with the markers
|
|
45
|
+
* deleted: the item holding the text is withheld, and a gated text is the withheld marker alone. No replacement matches
|
|
46
|
+
* a rule, so redacting twice changes nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function redactSecrets(input, options = {}) {
|
|
49
|
+
const result = redactSecretPasses(input, options);
|
|
50
|
+
if (result.withheld || !v1GateFires(result.text)) return result;
|
|
51
|
+
return { text: WITHHELD_MARKER, redactions: result.redactions + 1, withheld: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every string value and key of a JSON value through redactSecrets, the whole string value of an entry whose key names
|
|
56
|
+
* a secret through secretKeyReplacement, and every string leaf under such a key through secretLeafReplacement; other
|
|
57
|
+
* values are kept as they are. `withheld` says some string held a block with no end.
|
|
58
|
+
*/
|
|
59
|
+
export function redactSecretsDeep(value, options = {}) {
|
|
60
|
+
let redactions = 0;
|
|
61
|
+
let withheld = false;
|
|
62
|
+
const text = (entry) => {
|
|
63
|
+
const result = redactSecrets(entry, options);
|
|
64
|
+
redactions += result.redactions;
|
|
65
|
+
withheld ||= result.withheld === true;
|
|
66
|
+
return result.text;
|
|
67
|
+
};
|
|
68
|
+
const walk = (entry, family = null) => {
|
|
69
|
+
if (typeof entry === 'string') {
|
|
70
|
+
const replacement = family && secretLeafReplacement(family, entry);
|
|
71
|
+
if (!replacement) return text(entry);
|
|
72
|
+
redactions += 1;
|
|
73
|
+
return replacement;
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(entry)) return entry.map((item) => walk(item, family));
|
|
76
|
+
if (entry && typeof entry === 'object') {
|
|
77
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => {
|
|
78
|
+
const replacement = secretKeyReplacement(key, item);
|
|
79
|
+
if (replacement) redactions += 1;
|
|
80
|
+
return [text(key), replacement ?? walk(item, family ?? secretSubtreeFamily(key, item))];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
return entry;
|
|
84
|
+
};
|
|
85
|
+
const redacted = walk(value);
|
|
86
|
+
return withheld ? { value: redacted, redactions, withheld } : { value: redacted, redactions };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function redactText(input, { roots = [], maxBytes = 32 * 1024 } = {}) {
|
|
90
|
+
let { text, redactions } = redactSecrets(input);
|
|
91
|
+
for (const root of [...roots].filter(Boolean).sort((left, right) => right.length - left.length)) {
|
|
92
|
+
if (!text.includes(root)) continue;
|
|
93
|
+
redactions += text.split(root).length - 1;
|
|
94
|
+
text = text.split(root).join('<root>');
|
|
95
|
+
}
|
|
96
|
+
const bounded = truncateUtf8(text, maxBytes);
|
|
97
|
+
return { ...bounded, redactions };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function redactObject(value, options = {}) {
|
|
101
|
+
const maxBytes = options.maxBytes ?? 32 * 1024;
|
|
102
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 64) {
|
|
103
|
+
const error = new Error('Object redaction limit must be an integer of at least 64 bytes');
|
|
104
|
+
error.code = 'HUB_REDACTION_LIMIT_INVALID';
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
const roots = [...(options.roots || [])].filter(Boolean).sort((left, right) => right.length - left.length);
|
|
108
|
+
let withheld = false;
|
|
109
|
+
const text = (entry) => {
|
|
110
|
+
const result = redactSecrets(entry);
|
|
111
|
+
withheld ||= result.withheld === true;
|
|
112
|
+
return roots.reduce((out, root) => out.split(root).join('<root>'), result.text);
|
|
113
|
+
};
|
|
114
|
+
const walk = (entry, family = null) => {
|
|
115
|
+
if (typeof entry === 'string') return (family && secretLeafReplacement(family, entry)) || text(entry);
|
|
116
|
+
if (Array.isArray(entry)) return entry.map((item) => walk(item, family));
|
|
117
|
+
if (entry && typeof entry === 'object') {
|
|
118
|
+
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [
|
|
119
|
+
text(key),
|
|
120
|
+
secretKeyReplacement(key, item) ?? walk(item, family ?? secretSubtreeFamily(key, item)),
|
|
121
|
+
]));
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
};
|
|
125
|
+
const redacted = walk(value === undefined ? null : value);
|
|
126
|
+
if (!withheld && Buffer.byteLength(JSON.stringify(redacted), 'utf8') <= maxBytes) return redacted;
|
|
127
|
+
return { contentWithheld: true, redactionVersion: 1 };
|
|
128
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HubError } from './errors.js';
|
|
5
|
+
import {
|
|
6
|
+
ensureOwnerOnlyDirectory,
|
|
7
|
+
readOwnerOnlyJson,
|
|
8
|
+
writeOwnerOnlyJson,
|
|
9
|
+
} from './storage.js';
|
|
10
|
+
import { execFileBounded as execFile } from './subprocess.js';
|
|
11
|
+
|
|
12
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{2,79}$/;
|
|
13
|
+
const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,179}$/;
|
|
14
|
+
|
|
15
|
+
function isContained(root, candidate) {
|
|
16
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function exists(file) {
|
|
20
|
+
try {
|
|
21
|
+
await lstat(file);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function gitDirectory(root) {
|
|
30
|
+
const dotGit = path.join(root, '.git');
|
|
31
|
+
const info = await lstat(dotGit);
|
|
32
|
+
if (info.isDirectory()) return realpath(dotGit);
|
|
33
|
+
if (!info.isFile()) {
|
|
34
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Unsupported .git entry under ${root}`);
|
|
35
|
+
}
|
|
36
|
+
const contents = await readFile(dotGit, 'utf8');
|
|
37
|
+
const match = contents.match(/^gitdir:\s*(.+)\s*$/m);
|
|
38
|
+
if (!match) throw new HubError('HUB_NOT_REPOSITORY', `Invalid .git file under ${root}`);
|
|
39
|
+
return realpath(path.resolve(root, match[1]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function repositoryIdentity(canonicalRoot) {
|
|
43
|
+
let gitDir;
|
|
44
|
+
try {
|
|
45
|
+
gitDir = await gitDirectory(canonicalRoot);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === 'ENOENT') {
|
|
48
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Approved root is not a Git repository: ${canonicalRoot}`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const rootInfo = await lstat(canonicalRoot);
|
|
53
|
+
const gitInfo = await lstat(gitDir);
|
|
54
|
+
return createHash('sha256')
|
|
55
|
+
.update(
|
|
56
|
+
[
|
|
57
|
+
canonicalRoot,
|
|
58
|
+
rootInfo.dev,
|
|
59
|
+
rootInfo.ino,
|
|
60
|
+
gitDir,
|
|
61
|
+
gitInfo.dev,
|
|
62
|
+
gitInfo.ino,
|
|
63
|
+
].join('\0')
|
|
64
|
+
)
|
|
65
|
+
.digest('hex');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WorktreeRegistry {
|
|
69
|
+
#queue = Promise.resolve();
|
|
70
|
+
|
|
71
|
+
static async open(directory) {
|
|
72
|
+
await ensureOwnerOnlyDirectory(directory);
|
|
73
|
+
const file = path.join(directory, 'worktrees.json');
|
|
74
|
+
const data = await readOwnerOnlyJson(file, { schemaVersion: 1, worktrees: {} });
|
|
75
|
+
if (data.schemaVersion !== 1 || !data.worktrees || typeof data.worktrees !== 'object') {
|
|
76
|
+
throw new HubError('HUB_WORKTREE_REGISTRY_INVALID', 'Worktree registry is invalid');
|
|
77
|
+
}
|
|
78
|
+
return new WorktreeRegistry(file, data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
constructor(file, data) {
|
|
82
|
+
this.file = file;
|
|
83
|
+
this.data = data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #mutate(operation) {
|
|
87
|
+
const pending = this.#queue.then(async () => {
|
|
88
|
+
const draft = structuredClone(this.data);
|
|
89
|
+
const result = operation(draft);
|
|
90
|
+
await writeOwnerOnlyJson(this.file, draft);
|
|
91
|
+
this.data = draft;
|
|
92
|
+
return structuredClone(result);
|
|
93
|
+
});
|
|
94
|
+
this.#queue = pending.catch(() => {});
|
|
95
|
+
return pending;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get(worktreeId) {
|
|
99
|
+
const record = Object.hasOwn(this.data.worktrees, worktreeId)
|
|
100
|
+
? this.data.worktrees[worktreeId]
|
|
101
|
+
: null;
|
|
102
|
+
return record ? structuredClone(record) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
list(rootId = null) {
|
|
106
|
+
return Object.values(this.data.worktrees)
|
|
107
|
+
.filter((record) => rootId === null || record.rootId === rootId)
|
|
108
|
+
.map((record) => structuredClone(record));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async add(record) {
|
|
112
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{5,160}$/.test(record.worktreeId)) {
|
|
113
|
+
throw new HubError('HUB_INVALID_WORKTREE_ID', 'Worktree record id is invalid');
|
|
114
|
+
}
|
|
115
|
+
return this.#mutate((draft) => {
|
|
116
|
+
if (Object.hasOwn(draft.worktrees, record.worktreeId)) {
|
|
117
|
+
throw new HubError('HUB_WORKTREE_EXISTS', `Worktree record already exists: ${record.worktreeId}`);
|
|
118
|
+
}
|
|
119
|
+
draft.worktrees[record.worktreeId] = structuredClone(record);
|
|
120
|
+
return record;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function captureRootGrant({ rootId, root, repoWrite = false, worktreeBase }) {
|
|
126
|
+
if (!SAFE_ID.test(rootId)) {
|
|
127
|
+
throw new HubError('HUB_INVALID_ROOT_ID', 'Root id must be opaque and path-free');
|
|
128
|
+
}
|
|
129
|
+
let canonicalRoot;
|
|
130
|
+
try {
|
|
131
|
+
canonicalRoot = await realpath(root);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
134
|
+
throw new HubError('HUB_ROOT_DENIED', `Root "${rootId}" does not exist: ${root}`);
|
|
135
|
+
}
|
|
136
|
+
const info = await lstat(canonicalRoot);
|
|
137
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
138
|
+
throw new HubError('HUB_ROOT_DENIED', 'Approved root must be a real directory');
|
|
139
|
+
}
|
|
140
|
+
if (!(await exists(path.join(canonicalRoot, '.git')))) {
|
|
141
|
+
throw new HubError('HUB_NOT_REPOSITORY', `Root "${rootId}" must be a git repository: ${canonicalRoot} has no .git`);
|
|
142
|
+
}
|
|
143
|
+
const configuredWorktreeBase =
|
|
144
|
+
worktreeBase ||
|
|
145
|
+
path.join(path.dirname(canonicalRoot), '.agentpulse-worktrees', rootId);
|
|
146
|
+
await ensureOwnerOnlyDirectory(configuredWorktreeBase);
|
|
147
|
+
const head = await readFile(path.join(await gitDirectory(canonicalRoot), 'HEAD'), 'utf8');
|
|
148
|
+
const branch = head.match(/^ref:\s*refs\/heads\/(.+)\s*$/)?.[1];
|
|
149
|
+
if (!branch) {
|
|
150
|
+
throw new HubError('HUB_INVALID_GIT_REF', 'Approved root must have a local branch checked out');
|
|
151
|
+
}
|
|
152
|
+
validateBaseRef(branch);
|
|
153
|
+
return {
|
|
154
|
+
rootId,
|
|
155
|
+
canonicalRoot,
|
|
156
|
+
repositoryIdentity: await repositoryIdentity(canonicalRoot),
|
|
157
|
+
worktreeBase: await realpath(configuredWorktreeBase),
|
|
158
|
+
baseRef: branch,
|
|
159
|
+
repoWrite: Boolean(repoWrite),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function validateRootGrant(grant) {
|
|
164
|
+
let currentRoot;
|
|
165
|
+
try {
|
|
166
|
+
currentRoot = await realpath(grant.canonicalRoot);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new HubError('HUB_ROOT_REPLACED', 'Approved root no longer exists', { cause: error });
|
|
169
|
+
}
|
|
170
|
+
const identity = await repositoryIdentity(currentRoot);
|
|
171
|
+
if (currentRoot !== grant.canonicalRoot || identity !== grant.repositoryIdentity) {
|
|
172
|
+
throw new HubError('HUB_ROOT_REPLACED', 'Approved root or repository identity changed');
|
|
173
|
+
}
|
|
174
|
+
return currentRoot;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function nearestExisting(candidate, boundary) {
|
|
178
|
+
let current = candidate;
|
|
179
|
+
while (!await exists(current)) {
|
|
180
|
+
const parent = path.dirname(current);
|
|
181
|
+
if (parent === current || !isContained(boundary, parent)) {
|
|
182
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Target has no existing parent inside the approved root');
|
|
183
|
+
}
|
|
184
|
+
current = parent;
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function rejectNestedRepository(root, target) {
|
|
190
|
+
let current = target;
|
|
191
|
+
const relative = path.relative(root, target);
|
|
192
|
+
const parts = relative ? relative.split(path.sep) : [];
|
|
193
|
+
current = root;
|
|
194
|
+
for (const part of parts) {
|
|
195
|
+
current = path.join(current, part);
|
|
196
|
+
const currentInfo = await lstat(current);
|
|
197
|
+
if (current !== root && currentInfo.isDirectory() && await exists(path.join(current, '.git'))) {
|
|
198
|
+
throw new HubError('HUB_NESTED_REPOSITORY', `Nested repository is not separately approved: ${current}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function resolveApprovedPath(
|
|
204
|
+
grant,
|
|
205
|
+
relativePath,
|
|
206
|
+
{ access = 'read', worktreeId = null, registry = null } = {}
|
|
207
|
+
) {
|
|
208
|
+
await validateRootGrant(grant);
|
|
209
|
+
if (
|
|
210
|
+
typeof relativePath !== 'string' ||
|
|
211
|
+
relativePath.length === 0 ||
|
|
212
|
+
relativePath.includes('\0') ||
|
|
213
|
+
relativePath.includes('\\') ||
|
|
214
|
+
path.isAbsolute(relativePath)
|
|
215
|
+
) {
|
|
216
|
+
throw new HubError('HUB_PATH_DENIED', 'Target must be a portable relative path');
|
|
217
|
+
}
|
|
218
|
+
const normalized = path.posix.normalize(relativePath);
|
|
219
|
+
if (normalized !== relativePath || normalized === '..' || normalized.startsWith('../')) {
|
|
220
|
+
throw new HubError('HUB_PATH_DENIED', 'Path traversal or non-canonical path is denied');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let boundary = grant.canonicalRoot;
|
|
224
|
+
if (access === 'write') {
|
|
225
|
+
if (!grant.repoWrite) {
|
|
226
|
+
throw new HubError('HUB_REPO_WRITE_DENIED', 'Approved root does not allow repository writes');
|
|
227
|
+
}
|
|
228
|
+
if (!worktreeId || !registry) {
|
|
229
|
+
throw new HubError('HUB_WRITE_REQUIRES_WORKTREE', 'Writes require a connector-owned worktree');
|
|
230
|
+
}
|
|
231
|
+
const record = registry.get(worktreeId);
|
|
232
|
+
if (!record || record.rootId !== grant.rootId) {
|
|
233
|
+
throw new HubError('HUB_WRITE_REQUIRES_WORKTREE', 'Worktree is not registered for the approved root');
|
|
234
|
+
}
|
|
235
|
+
boundary = await realpath(record.canonicalPath);
|
|
236
|
+
const boundaryInfo = await lstat(boundary);
|
|
237
|
+
const configuredBase = await realpath(grant.worktreeBase);
|
|
238
|
+
if (
|
|
239
|
+
!isContained(configuredBase, boundary) ||
|
|
240
|
+
path.dirname(boundary) !== configuredBase
|
|
241
|
+
) {
|
|
242
|
+
throw new HubError(
|
|
243
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
244
|
+
'Writes require a direct child of the connector-owned worktree directory'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const mainGit = await gitDirectory(grant.canonicalRoot);
|
|
249
|
+
const linkedGit = await gitDirectory(boundary);
|
|
250
|
+
if (
|
|
251
|
+
!isContained(path.join(mainGit, 'worktrees'), linkedGit) ||
|
|
252
|
+
linkedGit !== record.gitDirectory ||
|
|
253
|
+
boundary !== record.canonicalPath ||
|
|
254
|
+
boundaryInfo.dev !== record.pathDevice ||
|
|
255
|
+
boundaryInfo.ino !== record.pathInode ||
|
|
256
|
+
record.repositoryIdentity !== grant.repositoryIdentity
|
|
257
|
+
) {
|
|
258
|
+
throw new HubError(
|
|
259
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
260
|
+
'Write target is not a worktree of the approved repository'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof HubError && error.code === 'HUB_WRITE_REQUIRES_WORKTREE') throw error;
|
|
265
|
+
throw new HubError(
|
|
266
|
+
'HUB_WRITE_REQUIRES_WORKTREE',
|
|
267
|
+
'Write target is not a valid connector-owned worktree',
|
|
268
|
+
{ cause: error }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} else if (access !== 'read') {
|
|
272
|
+
throw new HubError('HUB_PATH_DENIED', `Unsupported access mode: ${access}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const candidate = path.resolve(boundary, relativePath);
|
|
276
|
+
if (!isContained(boundary, candidate)) {
|
|
277
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Target leaves the approved boundary');
|
|
278
|
+
}
|
|
279
|
+
const existing = await nearestExisting(candidate, boundary);
|
|
280
|
+
const canonicalExisting = await realpath(existing);
|
|
281
|
+
if (!isContained(boundary, canonicalExisting)) {
|
|
282
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Symlink resolves outside the approved boundary');
|
|
283
|
+
}
|
|
284
|
+
await rejectNestedRepository(boundary, existing);
|
|
285
|
+
return candidate;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function validateWorktreeIdentifier(value) {
|
|
289
|
+
if (!SAFE_ID.test(value)) {
|
|
290
|
+
throw new HubError('HUB_INVALID_WORKTREE_ID', 'Worktree identifiers must be opaque');
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validateBaseRef(value) {
|
|
296
|
+
if (
|
|
297
|
+
!SAFE_REF.test(value) ||
|
|
298
|
+
value.startsWith('-') ||
|
|
299
|
+
value.includes('..') ||
|
|
300
|
+
value.includes('@{') ||
|
|
301
|
+
value.endsWith('.lock')
|
|
302
|
+
) {
|
|
303
|
+
throw new HubError('HUB_INVALID_GIT_REF', 'Base ref is not safe');
|
|
304
|
+
}
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function createOwnedWorktree({
|
|
309
|
+
grant,
|
|
310
|
+
deliveryId,
|
|
311
|
+
streamId,
|
|
312
|
+
baseRef,
|
|
313
|
+
registry,
|
|
314
|
+
execute = execFile,
|
|
315
|
+
}) {
|
|
316
|
+
await validateRootGrant(grant);
|
|
317
|
+
if (!grant.repoWrite) {
|
|
318
|
+
throw new HubError('HUB_REPO_WRITE_DENIED', 'Approved root does not allow repository writes');
|
|
319
|
+
}
|
|
320
|
+
if (!registry) throw new HubError('HUB_WORKTREE_REGISTRY_REQUIRED', 'Worktree registry is required');
|
|
321
|
+
validateWorktreeIdentifier(deliveryId);
|
|
322
|
+
validateWorktreeIdentifier(streamId);
|
|
323
|
+
validateBaseRef(baseRef);
|
|
324
|
+
const worktreeBase = grant.worktreeBase;
|
|
325
|
+
await ensureOwnerOnlyDirectory(worktreeBase);
|
|
326
|
+
const name = `${deliveryId}-${streamId}`;
|
|
327
|
+
const destination = path.join(worktreeBase, name);
|
|
328
|
+
if (!isContained(worktreeBase, destination)) {
|
|
329
|
+
throw new HubError('HUB_ROOT_ESCAPE', 'Generated worktree path escaped its owner directory');
|
|
330
|
+
}
|
|
331
|
+
if (await exists(destination)) {
|
|
332
|
+
throw new HubError('HUB_WORKTREE_EXISTS', `Connector worktree already exists: ${name}`);
|
|
333
|
+
}
|
|
334
|
+
const branch = `pulse/${deliveryId}/${streamId}`;
|
|
335
|
+
await execute(
|
|
336
|
+
'git',
|
|
337
|
+
['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', branch, destination, baseRef],
|
|
338
|
+
{
|
|
339
|
+
cwd: grant.canonicalRoot,
|
|
340
|
+
timeout: 60_000,
|
|
341
|
+
maxBuffer: 1024 * 1024,
|
|
342
|
+
env: {
|
|
343
|
+
PATH: process.env.PATH || '/usr/bin:/bin',
|
|
344
|
+
LC_ALL: 'C',
|
|
345
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
346
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
347
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
348
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
const canonicalPath = await realpath(destination);
|
|
353
|
+
const pathInfo = await lstat(canonicalPath);
|
|
354
|
+
const linkedGit = await gitDirectory(canonicalPath);
|
|
355
|
+
const mainGit = await gitDirectory(grant.canonicalRoot);
|
|
356
|
+
if (!isContained(path.join(mainGit, 'worktrees'), linkedGit)) {
|
|
357
|
+
throw new HubError('HUB_WORKTREE_INVALID', 'Created worktree is not linked to the approved repository');
|
|
358
|
+
}
|
|
359
|
+
return registry.add({
|
|
360
|
+
worktreeId: name,
|
|
361
|
+
rootId: grant.rootId,
|
|
362
|
+
deliveryId,
|
|
363
|
+
streamId,
|
|
364
|
+
branch,
|
|
365
|
+
canonicalPath,
|
|
366
|
+
pathDevice: pathInfo.dev,
|
|
367
|
+
pathInode: pathInfo.ino,
|
|
368
|
+
gitDirectory: linkedGit,
|
|
369
|
+
repositoryIdentity: grant.repositoryIdentity,
|
|
370
|
+
createdAt: new Date().toISOString(),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DENIED_NAME = /^\.env|secret|token/i;
|
|
5
|
+
const REPO_DOCS = ['AGENTS.md', 'CLAUDE.md'];
|
|
6
|
+
const MAX_DEPTH = 4;
|
|
7
|
+
export const RULES_SUBDIR = path.join('.cursor', 'rules');
|
|
8
|
+
|
|
9
|
+
function unquote(value) {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (/^(["']).*\1$/.test(trimmed)) return trimmed.slice(1, -1);
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function listValue(value) {
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
const inner = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
|
|
18
|
+
return inner.split(',').map(unquote).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseRuleFile(text) {
|
|
22
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
23
|
+
const fields = {};
|
|
24
|
+
if (match) {
|
|
25
|
+
let listKey = null;
|
|
26
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
27
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
28
|
+
if (item && listKey) {
|
|
29
|
+
fields[listKey].push(unquote(item[1]));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const pair = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
|
|
33
|
+
if (!pair) continue;
|
|
34
|
+
listKey = pair[2].trim() === '' ? pair[1] : null;
|
|
35
|
+
fields[pair[1]] = listKey ? [] : pair[2];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const globs = fields.globs;
|
|
39
|
+
return {
|
|
40
|
+
description: typeof fields.description === 'string' && fields.description.trim() ? unquote(fields.description) : null,
|
|
41
|
+
alwaysApply: typeof fields.alwaysApply === 'string' && fields.alwaysApply.trim() === 'true',
|
|
42
|
+
globs: Array.isArray(globs) ? globs.filter(Boolean) : typeof globs === 'string' ? listValue(globs) : [],
|
|
43
|
+
body: match ? text.slice(match[0].length) : text,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function canonical(target) {
|
|
48
|
+
try {
|
|
49
|
+
return await realpath(target);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contained(root, candidate) {
|
|
56
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class PackBuilder {
|
|
60
|
+
constructor({ id, name, source, repo, maxRulesPerPack, maxRuleBytes, skipped }) {
|
|
61
|
+
this.pack = { id, name, source, ...(repo ? { repo } : {}), rules: [] };
|
|
62
|
+
this.maxRulesPerPack = maxRulesPerPack;
|
|
63
|
+
this.maxRuleBytes = maxRuleBytes;
|
|
64
|
+
this.skipped = skipped;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
skip(file, reason) {
|
|
68
|
+
this.skipped.push({ pack: this.pack.id, file, reason });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async add({ file, absolute, boundary, alwaysApply = null }) {
|
|
72
|
+
if (DENIED_NAME.test(path.basename(file))) return this.skip(file, 'denied_name');
|
|
73
|
+
const info = await lstat(absolute);
|
|
74
|
+
let target = absolute;
|
|
75
|
+
if (info.isSymbolicLink()) {
|
|
76
|
+
target = await canonical(absolute);
|
|
77
|
+
if (!target || !contained(boundary, target)) return this.skip(file, 'outside_pack');
|
|
78
|
+
}
|
|
79
|
+
const targetInfo = await stat(target);
|
|
80
|
+
if (!targetInfo.isFile()) return this.skip(file, 'not_a_rule');
|
|
81
|
+
if (targetInfo.size > this.maxRuleBytes) return this.skip(file, 'too_large');
|
|
82
|
+
if (this.pack.rules.length >= this.maxRulesPerPack) return this.skip(file, 'pack_full');
|
|
83
|
+
const parsed = parseRuleFile(await readFile(target, 'utf8'));
|
|
84
|
+
this.pack.rules.push({
|
|
85
|
+
name: file.endsWith('.mdc') ? path.basename(file, '.mdc') : path.basename(file),
|
|
86
|
+
file,
|
|
87
|
+
description: parsed.description,
|
|
88
|
+
alwaysApply: alwaysApply ?? parsed.alwaysApply,
|
|
89
|
+
globs: parsed.globs,
|
|
90
|
+
body: parsed.body,
|
|
91
|
+
});
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async addRulesDirectory(rulesDir) {
|
|
96
|
+
const boundary = await canonical(rulesDir);
|
|
97
|
+
if (!boundary) return;
|
|
98
|
+
const walk = async (directory, relative, depth) => {
|
|
99
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
|
|
100
|
+
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
101
|
+
);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
104
|
+
const absolute = path.join(directory, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (DENIED_NAME.test(entry.name)) this.skip(file, 'denied_name');
|
|
107
|
+
else if (depth < MAX_DEPTH) await walk(absolute, file, depth + 1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (DENIED_NAME.test(entry.name)) this.skip(file, 'denied_name');
|
|
111
|
+
else if (!entry.name.endsWith('.mdc')) this.skip(file, 'not_a_rule');
|
|
112
|
+
else await this.add({ file, absolute, boundary });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await walk(boundary, '', 0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readRulePacks({
|
|
120
|
+
workspace,
|
|
121
|
+
home,
|
|
122
|
+
repos = [],
|
|
123
|
+
maxRulesPerPack = 100,
|
|
124
|
+
maxRuleBytes = 64 * 1024,
|
|
125
|
+
rulesSubdir = RULES_SUBDIR,
|
|
126
|
+
}) {
|
|
127
|
+
const skipped = [];
|
|
128
|
+
const packs = [];
|
|
129
|
+
const options = { maxRulesPerPack, maxRuleBytes, skipped };
|
|
130
|
+
const finish = (builder) => {
|
|
131
|
+
if (builder.pack.rules.length > 0) packs.push(builder.pack);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (workspace) {
|
|
135
|
+
const builder = new PackBuilder({ id: 'workspace', name: 'Workspace rules', source: 'workspace', ...options });
|
|
136
|
+
await builder.addRulesDirectory(path.join(workspace, rulesSubdir));
|
|
137
|
+
finish(builder);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const seen = new Set([await canonical(workspace || '')]);
|
|
141
|
+
const repoPacks = [];
|
|
142
|
+
for (const repo of repos) {
|
|
143
|
+
const root = await canonical(repo.root);
|
|
144
|
+
if (!root || seen.has(root)) continue;
|
|
145
|
+
seen.add(root);
|
|
146
|
+
const builder = new PackBuilder({ id: `repo:${repo.name}`, name: repo.name, source: 'repo', repo: repo.name, ...options });
|
|
147
|
+
await builder.addRulesDirectory(path.join(root, rulesSubdir));
|
|
148
|
+
for (const doc of REPO_DOCS) {
|
|
149
|
+
const absolute = path.join(root, doc);
|
|
150
|
+
if (await canonical(absolute)) await builder.add({ file: doc, absolute, boundary: root, alwaysApply: true });
|
|
151
|
+
}
|
|
152
|
+
repoPacks.push(builder);
|
|
153
|
+
}
|
|
154
|
+
repoPacks.sort((left, right) => (left.pack.id < right.pack.id ? -1 : 1)).forEach(finish);
|
|
155
|
+
|
|
156
|
+
if (home) {
|
|
157
|
+
const builder = new PackBuilder({ id: 'user', name: 'User rules', source: 'user', ...options });
|
|
158
|
+
await builder.addRulesDirectory(path.join(home, rulesSubdir));
|
|
159
|
+
finish(builder);
|
|
160
|
+
}
|
|
161
|
+
return { packs, skipped };
|
|
162
|
+
}
|