atris 3.38.0 → 3.41.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/AGENTS.md +25 -6
- package/atris/PERSONA.md +8 -4
- package/atris.md +7 -0
- package/ax +2 -1
- package/bin/atris.js +31 -6
- package/commands/agent-spawn.js +13 -11
- package/commands/autoland.js +28 -77
- package/commands/bench.js +10 -12
- package/commands/business.js +345 -0
- package/commands/chat-scan.js +5 -7
- package/commands/codex-goal.js +8 -10
- package/commands/computer.js +20 -0
- package/commands/console.js +19 -3
- package/commands/decide.js +166 -0
- package/commands/deck.js +1 -4
- package/commands/drill.js +14 -24
- package/commands/engine.js +196 -8
- package/commands/gm.js +8 -6
- package/commands/harvest.js +1 -4
- package/commands/init.js +23 -3
- package/commands/land.js +8 -14
- package/commands/launchpad.js +1 -14
- package/commands/lifecycle.js +5 -5
- package/commands/log.js +55 -5
- package/commands/member.js +558 -574
- package/commands/mission.js +456 -237
- package/commands/pack.js +2746 -164
- package/commands/play.js +6 -4
- package/commands/probe.js +2 -2
- package/commands/pulse.js +15 -16
- package/commands/release.js +10 -9
- package/commands/router.js +5 -4
- package/commands/site-deploy.js +885 -0
- package/commands/site.js +11 -2
- package/commands/slop.js +14 -2
- package/commands/stream.js +4 -18
- package/commands/task.js +880 -558
- package/commands/taste.js +101 -0
- package/commands/team.js +176 -3
- package/commands/vercel.js +4 -2
- package/commands/voice.js +195 -0
- package/commands/watch.js +1 -22
- package/commands/wiki.js +1 -4
- package/commands/workflow.js +2 -2
- package/commands/worktree.js +1 -14
- package/commands/xp.js +27 -24
- package/lib/accept-verify-gate.js +5 -1
- package/lib/arg-parser.js +41 -0
- package/lib/auto-accept-certified.js +116 -1
- package/lib/autoland.js +66 -0
- package/lib/bench/runner.js +19 -1
- package/lib/context-gatherer.js +7 -1
- package/lib/engine-registry.js +141 -20
- package/lib/falsifier-probe.js +84 -0
- package/lib/fleet.js +65 -15
- package/lib/git-spawn.js +15 -0
- package/lib/json-file.js +37 -0
- package/lib/known-commands.js +2 -2
- package/lib/lesson-preflight.js +146 -0
- package/lib/loop-doctor.js +0 -2
- package/lib/mission-human-asks.js +28 -0
- package/lib/mission-protected-lane.js +4 -1
- package/lib/official-cli-integration.js +47 -2
- package/lib/orb-context.js +8 -1
- package/lib/pack-capabilities.js +685 -0
- package/lib/router-brain.js +51 -1
- package/lib/runner-command.js +0 -6
- package/lib/self-drive.js +44 -13
- package/lib/task-db.js +137 -3
- package/lib/task-decision.js +50 -0
- package/lib/taste-lessons.js +153 -0
- package/lib/tool-result-encode.js +17 -1
- package/lib/voice-gate.js +66 -0
- package/lib/wish-audit.js +1 -1
- package/lib/wish-delegate.js +1 -1
- package/lib/zip.js +95 -7
- package/package.json +2 -1
- package/templates/business-starter/persona.md +9 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const dns = require('dns').promises;
|
|
7
|
+
const net = require('net');
|
|
8
|
+
const { randomUUID } = require('crypto');
|
|
9
|
+
|
|
10
|
+
const CAPABILITY_DEFINITIONS = Object.freeze({
|
|
11
|
+
'pack.read': Object.freeze({
|
|
12
|
+
description: 'read and search files inside the pack root',
|
|
13
|
+
tools: Object.freeze(['Read', 'Glob', 'Grep', 'Skill']),
|
|
14
|
+
}),
|
|
15
|
+
'pack.write': Object.freeze({
|
|
16
|
+
description: 'read, create, and edit files inside the pack root',
|
|
17
|
+
tools: Object.freeze(['Read', 'Glob', 'Grep', 'Skill', 'Edit', 'Write']),
|
|
18
|
+
}),
|
|
19
|
+
'web.read': Object.freeze({
|
|
20
|
+
description: 'fetch and search the public web',
|
|
21
|
+
tools: Object.freeze(['WebFetch', 'WebSearch']),
|
|
22
|
+
}),
|
|
23
|
+
'host.shell': Object.freeze({
|
|
24
|
+
description: 'run unrestricted host shell commands (includes host files and network)',
|
|
25
|
+
tools: Object.freeze(['Bash']),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const TOOL_ORDER = Object.freeze([
|
|
30
|
+
'Read', 'Glob', 'Grep', 'Skill',
|
|
31
|
+
'Edit', 'Write',
|
|
32
|
+
'WebFetch', 'WebSearch',
|
|
33
|
+
'Bash',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const FILE_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Edit', 'Write']);
|
|
37
|
+
const CLAUDE_SESSION_END_REASONS = new Set([
|
|
38
|
+
'clear',
|
|
39
|
+
'resume',
|
|
40
|
+
'logout',
|
|
41
|
+
'prompt_input_exit',
|
|
42
|
+
'bypass_permissions_disabled',
|
|
43
|
+
'other',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function canonicalCapabilityNames() {
|
|
47
|
+
return Object.keys(CAPABILITY_DEFINITIONS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toolsForCapabilities(capabilities) {
|
|
51
|
+
const granted = new Set();
|
|
52
|
+
for (const capability of capabilities) {
|
|
53
|
+
for (const tool of CAPABILITY_DEFINITIONS[capability].tools) granted.add(tool);
|
|
54
|
+
}
|
|
55
|
+
return TOOL_ORDER.filter((tool) => granted.has(tool));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolvePackCapabilityPolicy(value) {
|
|
59
|
+
if (value === undefined || value === null) {
|
|
60
|
+
return { status: 'legacy', requested: [], grantedCapabilities: [], tools: [] };
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(value)) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'invalid',
|
|
65
|
+
requested: [],
|
|
66
|
+
tools: [],
|
|
67
|
+
reason: `permissions must be an array of canonical capabilities (${canonicalCapabilityNames().join(', ')})`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const requested = [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const raw of value) {
|
|
74
|
+
if (typeof raw !== 'string' || raw.trim() !== raw || !raw) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'invalid',
|
|
77
|
+
requested: [],
|
|
78
|
+
tools: [],
|
|
79
|
+
reason: 'permissions entries must be non-empty strings without surrounding whitespace',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (!CAPABILITY_DEFINITIONS[raw]) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'invalid',
|
|
85
|
+
requested: [],
|
|
86
|
+
tools: [],
|
|
87
|
+
reason: `unknown capability ${JSON.stringify(raw)}; supported capabilities: ${canonicalCapabilityNames().join(', ')}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (seen.has(raw)) {
|
|
91
|
+
return {
|
|
92
|
+
status: 'invalid',
|
|
93
|
+
requested: [],
|
|
94
|
+
tools: [],
|
|
95
|
+
reason: `duplicate capability ${JSON.stringify(raw)}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
seen.add(raw);
|
|
99
|
+
requested.push(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
status: 'enforced',
|
|
104
|
+
requested,
|
|
105
|
+
grantedCapabilities: [...requested],
|
|
106
|
+
tools: toolsForCapabilities(requested),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertPackCapabilityPolicy(value) {
|
|
111
|
+
const policy = resolvePackCapabilityPolicy(value);
|
|
112
|
+
if (policy.status === 'invalid') throw new Error(`pack.json ${policy.reason}`);
|
|
113
|
+
return policy;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyPackCapabilityGrants(policy, grants = []) {
|
|
117
|
+
if (!grants.length) return policy;
|
|
118
|
+
const grantPolicy = resolvePackCapabilityPolicy(grants);
|
|
119
|
+
if (grantPolicy.status === 'invalid') throw new Error(`--grant ${grantPolicy.reason}`);
|
|
120
|
+
const grantedCapabilities = [...new Set([
|
|
121
|
+
...(policy.status === 'enforced' ? policy.grantedCapabilities : []),
|
|
122
|
+
...grantPolicy.requested,
|
|
123
|
+
])];
|
|
124
|
+
return {
|
|
125
|
+
status: 'enforced',
|
|
126
|
+
requested: policy.status === 'enforced' ? [...policy.requested] : [],
|
|
127
|
+
grantedCapabilities,
|
|
128
|
+
tools: toolsForCapabilities(grantedCapabilities),
|
|
129
|
+
operatorEscalated: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Atris reads a small amount of pack content before Claude starts: the
|
|
134
|
+
// entrypoint, persona, task count, and skill metadata. Claude's tool hooks do
|
|
135
|
+
// not exist yet at that point, so a symlink anywhere in a declared pack could
|
|
136
|
+
// otherwise make the launcher itself cross the advertised pack-root boundary.
|
|
137
|
+
// Published ZIPs cannot contain symlinks; apply the same portable-artifact
|
|
138
|
+
// contract to declared local folders before any execution context is gathered.
|
|
139
|
+
function assertPackExecutionTree(packDir) {
|
|
140
|
+
const root = fs.realpathSync(packDir);
|
|
141
|
+
|
|
142
|
+
function visit(dir) {
|
|
143
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
144
|
+
const absolute = path.join(dir, entry.name);
|
|
145
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
146
|
+
const stat = fs.lstatSync(absolute);
|
|
147
|
+
if (stat.isSymbolicLink()) {
|
|
148
|
+
throw new Error(`declared pack execution tree cannot contain symlinks: ${relative}`);
|
|
149
|
+
}
|
|
150
|
+
if (stat.isDirectory()) {
|
|
151
|
+
visit(absolute);
|
|
152
|
+
} else if (!stat.isFile()) {
|
|
153
|
+
throw new Error(`declared pack execution tree contains an unsupported file: ${relative}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
visit(root);
|
|
159
|
+
return root;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readClaudeUserDenyRules(options = {}) {
|
|
163
|
+
const configDir = path.resolve(
|
|
164
|
+
options.configDir
|
|
165
|
+
|| process.env.CLAUDE_CONFIG_DIR
|
|
166
|
+
|| path.join(os.homedir(), '.claude'),
|
|
167
|
+
);
|
|
168
|
+
try {
|
|
169
|
+
const settings = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8'));
|
|
170
|
+
const deny = settings && settings.permissions && settings.permissions.deny;
|
|
171
|
+
if (!Array.isArray(deny)) return [];
|
|
172
|
+
return [...new Set(deny.filter((rule) => typeof rule === 'string' && rule.trim()))];
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function toolCapability(tool, requested) {
|
|
179
|
+
for (const capability of requested) {
|
|
180
|
+
if (CAPABILITY_DEFINITIONS[capability].tools.includes(tool)) return capability;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function trustedAllowRules(policy) {
|
|
186
|
+
// `dontAsk` denies every call that is not explicitly pre-approved. Keep the
|
|
187
|
+
// approval list identical to the --tools ceiling; the PreToolUse hook below
|
|
188
|
+
// remains the authority that confines built-in file tools to the pack root.
|
|
189
|
+
return [...policy.tools];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function shellQuote(value) {
|
|
193
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildClaudeCapabilityArgs(policy, options = {}) {
|
|
197
|
+
if (!policy || policy.status !== 'enforced') return [];
|
|
198
|
+
const trust = options.trust === true;
|
|
199
|
+
const userDenyRules = Array.isArray(options.userDenyRules) ? options.userDenyRules : [];
|
|
200
|
+
const hookScript = options.hookScript || __filename;
|
|
201
|
+
const hookCommand = `${shellQuote(process.execPath)} ${shellQuote(hookScript)}`;
|
|
202
|
+
const settings = {
|
|
203
|
+
disableAllHooks: false,
|
|
204
|
+
disableSkillShellExecution: true,
|
|
205
|
+
permissions: {
|
|
206
|
+
defaultMode: trust ? 'dontAsk' : 'default',
|
|
207
|
+
disableBypassPermissionsMode: 'disable',
|
|
208
|
+
disableAutoMode: 'disable',
|
|
209
|
+
...(userDenyRules.length ? { deny: [...userDenyRules] } : {}),
|
|
210
|
+
...(trust ? { allow: trustedAllowRules(policy) } : {}),
|
|
211
|
+
},
|
|
212
|
+
hooks: {
|
|
213
|
+
PreToolUse: [{
|
|
214
|
+
matcher: 'Read|Glob|Grep|Edit|Write|WebFetch',
|
|
215
|
+
hooks: [{ type: 'command', command: `${hookCommand} pre` }],
|
|
216
|
+
}],
|
|
217
|
+
PostToolUse: [{
|
|
218
|
+
hooks: [{ type: 'command', command: `${hookCommand} used` }],
|
|
219
|
+
}],
|
|
220
|
+
PostToolUseFailure: [{
|
|
221
|
+
hooks: [{ type: 'command', command: `${hookCommand} failed` }],
|
|
222
|
+
}],
|
|
223
|
+
SessionEnd: [{
|
|
224
|
+
hooks: [{ type: 'command', command: `${hookCommand} session-end` }],
|
|
225
|
+
}],
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return [
|
|
230
|
+
'--tools', policy.tools.join(','),
|
|
231
|
+
'--permission-mode', trust ? 'dontAsk' : 'default',
|
|
232
|
+
'--no-chrome',
|
|
233
|
+
...(options.nonInteractive ? ['--no-session-persistence'] : []),
|
|
234
|
+
'--setting-sources', '',
|
|
235
|
+
'--strict-mcp-config',
|
|
236
|
+
'--mcp-config', JSON.stringify({ mcpServers: {} }),
|
|
237
|
+
'--settings', JSON.stringify(settings),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function receiptDirectory(options = {}) {
|
|
242
|
+
if (options.receiptDir) return path.resolve(options.receiptDir);
|
|
243
|
+
if (process.env.ATRIS_PACK_RUNS_DIR) return path.resolve(process.env.ATRIS_PACK_RUNS_DIR);
|
|
244
|
+
return path.join(os.homedir(), '.atris', 'runs', 'packs');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function appendReceiptEvent(eventsPath, event) {
|
|
248
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readReceiptEvents(eventsPath) {
|
|
252
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
253
|
+
return fs.readFileSync(eventsPath, 'utf8')
|
|
254
|
+
.split('\n')
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.map((line) => {
|
|
257
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
258
|
+
})
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finalizePackRunReceipt(receiptPath, eventsPath) {
|
|
263
|
+
const events = readReceiptEvents(eventsPath);
|
|
264
|
+
const launch = events.find((event) => event.event === 'launch');
|
|
265
|
+
if (!launch) throw new Error('pack run receipt is missing its launch event');
|
|
266
|
+
const usedTools = [...new Set(events.filter((event) => event.event === 'used').map((event) => event.tool))];
|
|
267
|
+
const usedCapabilities = launch.grantedCapabilities.filter((capability) => (
|
|
268
|
+
usedTools.some((tool) => toolCapability(tool, [capability]) === capability)
|
|
269
|
+
));
|
|
270
|
+
const exit = [...events].reverse().find((event) => event.event === 'exit');
|
|
271
|
+
const sessionEnd = [...events].reverse().find((event) => event.event === 'session-end');
|
|
272
|
+
const summary = {
|
|
273
|
+
schema: launch.schema,
|
|
274
|
+
runId: launch.runId,
|
|
275
|
+
status: exit ? 'finished' : sessionEnd ? 'session-ended' : 'running',
|
|
276
|
+
startedAt: launch.startedAt,
|
|
277
|
+
...(sessionEnd ? {
|
|
278
|
+
sessionEndedAt: sessionEnd.at,
|
|
279
|
+
sessionEndReason: sessionEnd.reason,
|
|
280
|
+
} : {}),
|
|
281
|
+
...(exit ? { finishedAt: exit.at, exitStatus: exit.status, signal: exit.signal || null } : {}),
|
|
282
|
+
pack: launch.pack,
|
|
283
|
+
...(launch.launcher ? { launcher: launch.launcher } : {}),
|
|
284
|
+
...(launch.operatorInput ? { operatorInput: launch.operatorInput } : {}),
|
|
285
|
+
approvalMode: launch.approvalMode,
|
|
286
|
+
requestedCapabilities: launch.requestedCapabilities,
|
|
287
|
+
grantedCapabilities: launch.grantedCapabilities,
|
|
288
|
+
grantedTools: launch.grantedTools,
|
|
289
|
+
usedCapabilities,
|
|
290
|
+
usedTools,
|
|
291
|
+
deniedUses: events.filter((event) => event.event === 'denied').map(({ at, tool, reason }) => ({ at, tool, reason })),
|
|
292
|
+
failedTools: events.filter((event) => event.event === 'failed').map(({ at, tool }) => ({ at, tool })),
|
|
293
|
+
observability: {
|
|
294
|
+
denialCoverage: 'atris-hooks-only',
|
|
295
|
+
runtimePermissionDenialsCaptured: false,
|
|
296
|
+
toolInputsLogged: false,
|
|
297
|
+
directSkillInvocationsCaptured: false,
|
|
298
|
+
runnerExitCaptured: Boolean(exit),
|
|
299
|
+
},
|
|
300
|
+
enforcement: launch.enforcement,
|
|
301
|
+
events: path.basename(eventsPath),
|
|
302
|
+
};
|
|
303
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true, mode: 0o700 });
|
|
304
|
+
const temporary = `${receiptPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
305
|
+
fs.writeFileSync(temporary, `${JSON.stringify(summary, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
306
|
+
fs.renameSync(temporary, receiptPath);
|
|
307
|
+
return summary;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function defaultProcessExists(pid) {
|
|
311
|
+
try {
|
|
312
|
+
process.kill(pid, 0);
|
|
313
|
+
return true;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error && error.code === 'EPERM') return true;
|
|
316
|
+
if (error && error.code === 'ESRCH') return false;
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A missing Atris launcher does not prove that its child runner stopped. Keep
|
|
322
|
+
// append-only receipt truth intact and derive the narrower fact consumers can
|
|
323
|
+
// trust: whether Atris still owns the lifecycle of a receipt recorded as live.
|
|
324
|
+
function classifyPackRunLifecycle(receipt, options = {}) {
|
|
325
|
+
const recordedStatus = receipt && receipt.status ? receipt.status : 'unknown';
|
|
326
|
+
if (recordedStatus === 'finished') {
|
|
327
|
+
return {
|
|
328
|
+
status: 'finished', recordedStatus, launcherStatus: 'not-needed', runnerStatus: 'ended',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (recordedStatus === 'session-ended') {
|
|
332
|
+
return {
|
|
333
|
+
status: 'session-ended', recordedStatus, launcherStatus: 'not-needed', runnerStatus: 'session-ended',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (recordedStatus !== 'running') {
|
|
337
|
+
return {
|
|
338
|
+
status: 'unknown', recordedStatus, launcherStatus: 'unknown', runnerStatus: 'unknown',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const pid = receipt && receipt.launcher && Number(receipt.launcher.pid);
|
|
343
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
344
|
+
return {
|
|
345
|
+
status: 'unknown', recordedStatus, launcherStatus: 'unknown', runnerStatus: 'unknown',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const processExists = options.processExists || defaultProcessExists;
|
|
349
|
+
const alive = processExists(pid);
|
|
350
|
+
if (alive === true) {
|
|
351
|
+
return {
|
|
352
|
+
status: 'running', recordedStatus, launcherStatus: 'active', runnerStatus: 'unknown',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (alive === false) {
|
|
356
|
+
return {
|
|
357
|
+
status: 'launcher-lost', recordedStatus, launcherStatus: 'lost', runnerStatus: 'unknown',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
status: 'unknown', recordedStatus, launcherStatus: 'unknown', runnerStatus: 'unknown',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function beginPackRunReceipt(packDir, manifest, policy, options = {}) {
|
|
366
|
+
const dir = receiptDirectory(options);
|
|
367
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
368
|
+
const now = options.now ? options.now() : new Date();
|
|
369
|
+
const runId = options.runId || randomUUID();
|
|
370
|
+
const slug = String(manifest.slug || manifest.name || 'pack').replace(/[^a-z0-9-]+/gi, '-').toLowerCase();
|
|
371
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
372
|
+
const base = `${stamp}-${slug}-${runId.slice(0, 8)}`;
|
|
373
|
+
const eventsPath = path.join(dir, `${base}.events.jsonl`);
|
|
374
|
+
const receiptPath = path.join(dir, `${base}.json`);
|
|
375
|
+
const launch = {
|
|
376
|
+
schema: 'atris.pack-run.v1',
|
|
377
|
+
event: 'launch',
|
|
378
|
+
runId,
|
|
379
|
+
startedAt: now.toISOString(),
|
|
380
|
+
pack: {
|
|
381
|
+
slug: manifest.slug || manifest.name || null,
|
|
382
|
+
version: manifest.version || null,
|
|
383
|
+
root: fs.realpathSync(packDir),
|
|
384
|
+
},
|
|
385
|
+
launcher: {
|
|
386
|
+
pid: Number.isSafeInteger(options.launcherPid) && options.launcherPid > 0
|
|
387
|
+
? options.launcherPid
|
|
388
|
+
: process.pid,
|
|
389
|
+
},
|
|
390
|
+
...(options.operatorInput ? { operatorInput: options.operatorInput } : {}),
|
|
391
|
+
approvalMode: options.trust ? 'pre-approved-within-declared-ceiling' : 'prompt-within-declared-ceiling',
|
|
392
|
+
requestedCapabilities: policy.requested,
|
|
393
|
+
grantedCapabilities: policy.grantedCapabilities,
|
|
394
|
+
grantedTools: policy.tools,
|
|
395
|
+
enforcement: {
|
|
396
|
+
runner: 'claude',
|
|
397
|
+
builtInToolCeiling: true,
|
|
398
|
+
packRootFileBoundary: true,
|
|
399
|
+
preLaunchContextBoundary: true,
|
|
400
|
+
declaredTreeSymlinksRejected: true,
|
|
401
|
+
packOpeningSlashCommandsEscaped: true,
|
|
402
|
+
claudeMemoryDisabledByRunner: true,
|
|
403
|
+
autoMemoryDisabledByRunner: true,
|
|
404
|
+
chromeIntegrationDisabledByRunner: true,
|
|
405
|
+
sessionPersistenceSuppressionRequested: true,
|
|
406
|
+
sessionPersistenceDisabledByRunner: options.nonInteractive === true,
|
|
407
|
+
sessionPersistenceMayApply: options.nonInteractive !== true,
|
|
408
|
+
openingPromptTransport: options.nonInteractive === true ? 'stdin' : 'argv',
|
|
409
|
+
runnerArgvContainsOpeningPrompt: options.nonInteractive !== true,
|
|
410
|
+
workspaceTrustPromptMayApply: options.nonInteractive !== true,
|
|
411
|
+
workspaceTrustDoesNotWidenToolCeiling: true,
|
|
412
|
+
webReadDestinationPreflight: 'literal-and-dns-private-address-deny',
|
|
413
|
+
webReadDnsRebindingNotPrevented: true,
|
|
414
|
+
subprocessCredentialScrubRequested: true,
|
|
415
|
+
userSettingsLoaded: false,
|
|
416
|
+
userDenyRulesImported: Number(options.userDenyRulesImported || 0),
|
|
417
|
+
userExtensionsLoaded: false,
|
|
418
|
+
projectSettingsLoaded: false,
|
|
419
|
+
managedPoliciesMayApply: true,
|
|
420
|
+
bundledClaudeSkillsMayApply: true,
|
|
421
|
+
packSkillsPluginLoaded: options.packSkillsPluginLoaded === true,
|
|
422
|
+
packSkillFrontmatterSanitized: true,
|
|
423
|
+
packSkillApprovalOverridesRemoved: true,
|
|
424
|
+
packSkillHooksRemoved: true,
|
|
425
|
+
skillShellExecutionDisabled: true,
|
|
426
|
+
mcpServersLoaded: false,
|
|
427
|
+
hostShellUnrestricted: policy.grantedCapabilities.includes('host.shell'),
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
appendReceiptEvent(eventsPath, launch);
|
|
431
|
+
finalizePackRunReceipt(receiptPath, eventsPath);
|
|
432
|
+
return { runId, receiptPath, eventsPath };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function insideRoot(candidate, root) {
|
|
436
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function realBoundaryPath(candidate) {
|
|
440
|
+
let cursor = candidate;
|
|
441
|
+
const suffix = [];
|
|
442
|
+
while (!fs.existsSync(cursor)) {
|
|
443
|
+
const parent = path.dirname(cursor);
|
|
444
|
+
if (parent === cursor) break;
|
|
445
|
+
suffix.unshift(path.basename(cursor));
|
|
446
|
+
cursor = parent;
|
|
447
|
+
}
|
|
448
|
+
const real = fs.realpathSync(cursor);
|
|
449
|
+
return path.resolve(real, ...suffix);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function unsafeGlobPattern(value) {
|
|
453
|
+
if (!value) return false;
|
|
454
|
+
const normalized = String(value).replace(/\\/g, '/');
|
|
455
|
+
return normalized.startsWith('/')
|
|
456
|
+
|| normalized.startsWith('~/')
|
|
457
|
+
|| normalized.split('/').includes('..');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function fileToolTarget(tool, input, root) {
|
|
461
|
+
if (tool === 'Glob' || tool === 'Grep') return input.path ? String(input.path) : root;
|
|
462
|
+
return input.file_path ? String(input.file_path) : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function enforcePackRoot(input, rootValue) {
|
|
466
|
+
const tool = input && input.tool_name;
|
|
467
|
+
if (!FILE_TOOLS.has(tool)) return { allowed: true };
|
|
468
|
+
const root = fs.realpathSync(rootValue);
|
|
469
|
+
const toolInput = input.tool_input || {};
|
|
470
|
+
if (tool === 'Glob' && unsafeGlobPattern(toolInput.pattern)) {
|
|
471
|
+
return { allowed: false, reason: 'Glob patterns cannot escape the pack root' };
|
|
472
|
+
}
|
|
473
|
+
if (tool === 'Grep' && unsafeGlobPattern(toolInput.glob)) {
|
|
474
|
+
return { allowed: false, reason: 'Grep glob filters cannot escape the pack root' };
|
|
475
|
+
}
|
|
476
|
+
const targetValue = fileToolTarget(tool, toolInput, root);
|
|
477
|
+
if (!targetValue) return { allowed: false, reason: `${tool} did not provide a path Atris can confine` };
|
|
478
|
+
const lexical = path.resolve(root, targetValue);
|
|
479
|
+
if (!insideRoot(lexical, root)) {
|
|
480
|
+
return { allowed: false, reason: `${tool} is confined to the pack root` };
|
|
481
|
+
}
|
|
482
|
+
let real;
|
|
483
|
+
try {
|
|
484
|
+
real = realBoundaryPath(lexical);
|
|
485
|
+
} catch {
|
|
486
|
+
return { allowed: false, reason: `${tool} target could not be resolved safely inside the pack root` };
|
|
487
|
+
}
|
|
488
|
+
if (!insideRoot(real, root)) {
|
|
489
|
+
return { allowed: false, reason: `${tool} cannot follow a symlink outside the pack root` };
|
|
490
|
+
}
|
|
491
|
+
return { allowed: true };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function normalizedHostname(value) {
|
|
495
|
+
const lower = String(value || '').toLowerCase().replace(/\.$/, '');
|
|
496
|
+
return lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function privateIpv4(address) {
|
|
500
|
+
const octets = address.split('.').map(Number);
|
|
501
|
+
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
const [a, b] = octets;
|
|
505
|
+
return a === 0
|
|
506
|
+
|| a === 10
|
|
507
|
+
|| a === 127
|
|
508
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
509
|
+
|| (a === 169 && b === 254)
|
|
510
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
511
|
+
|| (a === 192 && b === 0)
|
|
512
|
+
|| (a === 192 && b === 168)
|
|
513
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
514
|
+
|| a >= 224;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function privateIpv6(address) {
|
|
518
|
+
const value = address.toLowerCase();
|
|
519
|
+
if (value === '::' || value === '::1' || value.startsWith('::ffff:')) return true;
|
|
520
|
+
const first = Number.parseInt(value.split(':')[0] || '0', 16);
|
|
521
|
+
return (first & 0xfe00) === 0xfc00
|
|
522
|
+
|| (first & 0xffc0) === 0xfe80
|
|
523
|
+
|| (first & 0xff00) === 0xff00;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function privateNetworkAddress(address) {
|
|
527
|
+
const value = normalizedHostname(address);
|
|
528
|
+
const family = net.isIP(value);
|
|
529
|
+
if (family === 4) return privateIpv4(value);
|
|
530
|
+
if (family === 6) return privateIpv6(value);
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function localHostname(hostname) {
|
|
535
|
+
return hostname === 'localhost'
|
|
536
|
+
|| hostname.endsWith('.localhost')
|
|
537
|
+
|| hostname.endsWith('.local')
|
|
538
|
+
|| hostname.endsWith('.internal')
|
|
539
|
+
|| hostname.endsWith('.lan')
|
|
540
|
+
|| hostname.endsWith('.home')
|
|
541
|
+
|| hostname.endsWith('.home.arpa');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function publicWebUrlPreflight(input) {
|
|
545
|
+
if (!input || input.tool_name !== 'WebFetch') return { allowed: true };
|
|
546
|
+
const rawUrl = input.tool_input && input.tool_input.url;
|
|
547
|
+
if (typeof rawUrl !== 'string' || !rawUrl.trim()) {
|
|
548
|
+
return { allowed: false, reason: 'WebFetch did not provide a URL Atris can confine to public destinations' };
|
|
549
|
+
}
|
|
550
|
+
let parsed;
|
|
551
|
+
try {
|
|
552
|
+
parsed = new URL(rawUrl);
|
|
553
|
+
} catch {
|
|
554
|
+
return { allowed: false, reason: 'WebFetch URL is invalid' };
|
|
555
|
+
}
|
|
556
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
557
|
+
return { allowed: false, reason: 'WebFetch is confined to ordinary public HTTP(S) URLs without embedded credentials' };
|
|
558
|
+
}
|
|
559
|
+
const hostname = normalizedHostname(parsed.hostname);
|
|
560
|
+
if (!hostname || localHostname(hostname)) {
|
|
561
|
+
return { allowed: false, reason: 'WebFetch is confined to public network destinations' };
|
|
562
|
+
}
|
|
563
|
+
const family = net.isIP(hostname);
|
|
564
|
+
if (family && privateNetworkAddress(hostname)) {
|
|
565
|
+
return { allowed: false, reason: 'WebFetch is confined to public network destinations' };
|
|
566
|
+
}
|
|
567
|
+
return { allowed: true, hostname, needsDns: family === 0 };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function enforcePublicWeb(input, options = {}) {
|
|
571
|
+
const lexical = publicWebUrlPreflight(input);
|
|
572
|
+
if (!lexical.allowed || !lexical.needsDns) return lexical;
|
|
573
|
+
const lookup = options.lookup || dns.lookup;
|
|
574
|
+
let addresses;
|
|
575
|
+
try {
|
|
576
|
+
addresses = await lookup(lexical.hostname, { all: true, verbatim: true });
|
|
577
|
+
} catch {
|
|
578
|
+
return { allowed: false, reason: 'WebFetch destination could not be resolved as a public network address' };
|
|
579
|
+
}
|
|
580
|
+
if (!Array.isArray(addresses) || !addresses.length
|
|
581
|
+
|| addresses.some((entry) => !entry || privateNetworkAddress(entry.address))) {
|
|
582
|
+
return { allowed: false, reason: 'WebFetch is confined to public network destinations' };
|
|
583
|
+
}
|
|
584
|
+
return { allowed: true };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function hookEnvironment() {
|
|
588
|
+
const root = process.env.ATRIS_PACK_ROOT;
|
|
589
|
+
const receiptPath = process.env.ATRIS_PACK_RECEIPT;
|
|
590
|
+
const eventsPath = process.env.ATRIS_PACK_RECEIPT_EVENTS;
|
|
591
|
+
const grantedCapabilities = JSON.parse(process.env.ATRIS_PACK_GRANTED_CAPABILITIES || '[]');
|
|
592
|
+
if (!root || !receiptPath || !eventsPath) throw new Error('Atris pack hook environment is incomplete');
|
|
593
|
+
return { root, receiptPath, eventsPath, grantedCapabilities };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function readStdin() {
|
|
597
|
+
return fs.readFileSync(0, 'utf8').trim();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function denyPreToolUse(env, input, reason, now) {
|
|
601
|
+
appendReceiptEvent(env.eventsPath, {
|
|
602
|
+
event: 'denied', at: now, tool: input.tool_name || null, reason,
|
|
603
|
+
});
|
|
604
|
+
finalizePackRunReceipt(env.receiptPath, env.eventsPath);
|
|
605
|
+
return {
|
|
606
|
+
hookSpecificOutput: {
|
|
607
|
+
hookEventName: 'PreToolUse',
|
|
608
|
+
permissionDecision: 'deny',
|
|
609
|
+
permissionDecisionReason: reason,
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function runHook(mode, rawInput) {
|
|
615
|
+
const env = hookEnvironment();
|
|
616
|
+
const input = rawInput ? JSON.parse(rawInput) : {};
|
|
617
|
+
const now = new Date().toISOString();
|
|
618
|
+
if (mode === 'pre') {
|
|
619
|
+
const decision = enforcePackRoot(input, env.root);
|
|
620
|
+
if (!decision.allowed) {
|
|
621
|
+
return denyPreToolUse(env, input, decision.reason, now);
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (mode === 'used' || mode === 'failed') {
|
|
627
|
+
appendReceiptEvent(env.eventsPath, {
|
|
628
|
+
event: mode,
|
|
629
|
+
at: now,
|
|
630
|
+
tool: input.tool_name || null,
|
|
631
|
+
capability: toolCapability(input.tool_name, env.grantedCapabilities),
|
|
632
|
+
});
|
|
633
|
+
} else if (mode === 'session-end') {
|
|
634
|
+
const reason = CLAUDE_SESSION_END_REASONS.has(input.reason) ? input.reason : 'other';
|
|
635
|
+
appendReceiptEvent(env.eventsPath, { event: 'session-end', at: now, reason });
|
|
636
|
+
} else {
|
|
637
|
+
throw new Error(`unknown pack hook mode: ${mode}`);
|
|
638
|
+
}
|
|
639
|
+
finalizePackRunReceipt(env.receiptPath, env.eventsPath);
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function runHookAsync(mode, rawInput, options = {}) {
|
|
644
|
+
if (mode !== 'pre') return runHook(mode, rawInput);
|
|
645
|
+
const env = hookEnvironment();
|
|
646
|
+
const input = rawInput ? JSON.parse(rawInput) : {};
|
|
647
|
+
const now = new Date().toISOString();
|
|
648
|
+
const fileDecision = enforcePackRoot(input, env.root);
|
|
649
|
+
if (!fileDecision.allowed) return denyPreToolUse(env, input, fileDecision.reason, now);
|
|
650
|
+
const webDecision = await enforcePublicWeb(input, options);
|
|
651
|
+
if (!webDecision.allowed) return denyPreToolUse(env, input, webDecision.reason, now);
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (require.main === module) {
|
|
656
|
+
(async () => {
|
|
657
|
+
try {
|
|
658
|
+
const output = await runHookAsync(process.argv[2], readStdin());
|
|
659
|
+
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
process.stderr.write(`Atris pack capability hook failed: ${error.message}\n`);
|
|
662
|
+
process.exitCode = process.argv[2] === 'pre' ? 2 : 0;
|
|
663
|
+
}
|
|
664
|
+
})();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
module.exports = {
|
|
668
|
+
canonicalCapabilityNames,
|
|
669
|
+
resolvePackCapabilityPolicy,
|
|
670
|
+
assertPackCapabilityPolicy,
|
|
671
|
+
applyPackCapabilityGrants,
|
|
672
|
+
assertPackExecutionTree,
|
|
673
|
+
readClaudeUserDenyRules,
|
|
674
|
+
buildClaudeCapabilityArgs,
|
|
675
|
+
beginPackRunReceipt,
|
|
676
|
+
appendReceiptEvent,
|
|
677
|
+
finalizePackRunReceipt,
|
|
678
|
+
receiptDirectory,
|
|
679
|
+
classifyPackRunLifecycle,
|
|
680
|
+
enforcePackRoot,
|
|
681
|
+
publicWebUrlPreflight,
|
|
682
|
+
enforcePublicWeb,
|
|
683
|
+
runHook,
|
|
684
|
+
runHookAsync,
|
|
685
|
+
};
|