atris 3.34.0 → 3.35.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 +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +4 -2
- package/ax +475 -17
- package/bin/atris.js +197 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +554 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +250 -9
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
|
@@ -57,7 +57,10 @@ function missionRunIntentFromMessage(message) {
|
|
|
57
57
|
rest = cadenceFlag.text;
|
|
58
58
|
const noRun = hasBooleanFlag(rest, '--no-run') || hasBooleanFlag(rest, '--no-loop');
|
|
59
59
|
const noComplete = hasBooleanFlag(rest, '--no-complete');
|
|
60
|
+
const forceRoomPreflight = hasBooleanFlag(rest, '--preflight') || hasBooleanFlag(rest, '--room-preflight');
|
|
61
|
+
const skipRoomPreflight = hasBooleanFlag(rest, '--no-preflight') || hasBooleanFlag(rest, '--no-room-preflight') || !forceRoomPreflight;
|
|
60
62
|
rest = stripBooleanFlag(stripBooleanFlag(stripBooleanFlag(rest, '--no-run'), '--no-loop'), '--no-complete');
|
|
63
|
+
rest = stripBooleanFlag(stripBooleanFlag(stripBooleanFlag(stripBooleanFlag(rest, '--preflight'), '--room-preflight'), '--no-preflight'), '--no-room-preflight');
|
|
61
64
|
rest = rest.replace(/(^|\s)--json(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
|
|
62
65
|
|
|
63
66
|
const wantsLongRun = /\b(24\s*h|24\s*hours?|overnight|forever)\b/i.test(text);
|
|
@@ -69,6 +72,8 @@ function missionRunIntentFromMessage(message) {
|
|
|
69
72
|
cadence: cadenceFlag.value || (wantsLongRun ? '15m' : ''),
|
|
70
73
|
noRun,
|
|
71
74
|
noComplete,
|
|
75
|
+
roomPreflight: forceRoomPreflight,
|
|
76
|
+
noRoomPreflight: skipRoomPreflight,
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -245,6 +250,8 @@ async function runRuntimeMissionLoop(intent, options = {}) {
|
|
|
245
250
|
|
|
246
251
|
const startArgs = ['mission', 'run', intent.objective, '--runner', 'atris2', '--owner', intent.owner || 'mission-lead', '--json'];
|
|
247
252
|
if (intent.cadence) startArgs.push('--cadence', intent.cadence);
|
|
253
|
+
if (intent.roomPreflight) startArgs.push('--room-preflight');
|
|
254
|
+
else if (intent.noRoomPreflight) startArgs.push('--no-room-preflight');
|
|
248
255
|
const start = runCliJsonSync(cliPath, startArgs, { cwd, timeoutMs: 30000 });
|
|
249
256
|
const result = { ok: start.ok, status: start.status, start, noRun: intent.noRun === true, achieved: false };
|
|
250
257
|
if (!start.ok || !start.payload?.mission?.id) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const { spawnSync } = require('node:child_process');
|
|
2
|
+
|
|
3
|
+
function hasHelp(args = []) {
|
|
4
|
+
return args.includes('--help') || args.includes('-h') || args[0] === 'help';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function firstLine(value) {
|
|
8
|
+
return String(value || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || 'unknown';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatCommand(parts = []) {
|
|
12
|
+
return parts.join(' ').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runCaptured(binary, args = []) {
|
|
16
|
+
return spawnSync(binary, args, {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
env: process.env,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function missingBinary(result) {
|
|
23
|
+
return result && result.error && result.error.code === 'ENOENT';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printMissingBinary(config) {
|
|
27
|
+
console.error(`${config.name} cli not found.`);
|
|
28
|
+
console.error(`install: ${config.installHint}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureBinary(config) {
|
|
32
|
+
const result = runCaptured(config.binary, config.versionArgs || ['--version']);
|
|
33
|
+
if (missingBinary(result)) {
|
|
34
|
+
printMissingBinary(config);
|
|
35
|
+
return { ok: false, result };
|
|
36
|
+
}
|
|
37
|
+
if (result.error) {
|
|
38
|
+
console.error(`${config.name} cli check failed: ${result.error.message}`);
|
|
39
|
+
return { ok: false, result };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, result };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runAuthCheck(config) {
|
|
45
|
+
if (!config.authArgs || config.authArgs.length === 0) {
|
|
46
|
+
return { ok: true, skipped: true };
|
|
47
|
+
}
|
|
48
|
+
const result = runCaptured(config.binary, config.authArgs);
|
|
49
|
+
if (missingBinary(result)) {
|
|
50
|
+
printMissingBinary(config);
|
|
51
|
+
return { ok: false, result };
|
|
52
|
+
}
|
|
53
|
+
if (result.error) {
|
|
54
|
+
console.error(`${config.name} auth check failed: ${result.error.message}`);
|
|
55
|
+
return { ok: false, result };
|
|
56
|
+
}
|
|
57
|
+
return { ok: result.status === 0, result };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printHelp(config) {
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(`usage: atris ${config.name} <command> [args]`);
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log('commands:');
|
|
65
|
+
console.log(' doctor detect the cli binary and auth state');
|
|
66
|
+
console.log(' auth check auth state');
|
|
67
|
+
for (const command of config.commands) {
|
|
68
|
+
console.log(` ${command.usage.padEnd(13)} ${command.description}`);
|
|
69
|
+
}
|
|
70
|
+
console.log('');
|
|
71
|
+
console.log(`binary: ${config.binary}`);
|
|
72
|
+
console.log(`install: ${config.installHint}`);
|
|
73
|
+
console.log('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function printDoctor(config) {
|
|
77
|
+
const binary = ensureBinary(config);
|
|
78
|
+
if (!binary.ok) return 1;
|
|
79
|
+
|
|
80
|
+
console.log(`${config.name} integration`);
|
|
81
|
+
console.log(`binary: found ${config.binary}`);
|
|
82
|
+
console.log(`version: ${firstLine(binary.result.stdout || binary.result.stderr).toLowerCase()}`);
|
|
83
|
+
|
|
84
|
+
const auth = runAuthCheck(config);
|
|
85
|
+
if (auth.skipped) {
|
|
86
|
+
console.log('auth: not checked');
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (auth.ok) {
|
|
90
|
+
console.log('auth: ok');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
console.error('auth: failed');
|
|
94
|
+
console.error(`login: ${config.loginHint}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function printAuth(config) {
|
|
99
|
+
const binary = ensureBinary(config);
|
|
100
|
+
if (!binary.ok) return 1;
|
|
101
|
+
const auth = runAuthCheck(config);
|
|
102
|
+
if (auth.skipped || auth.ok) {
|
|
103
|
+
console.log('auth: ok');
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
console.error('auth: failed');
|
|
107
|
+
console.error(`login: ${config.loginHint}`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchCommand(config, args) {
|
|
112
|
+
return config.commands.find((command) => {
|
|
113
|
+
if (args.length < command.match.length) return false;
|
|
114
|
+
return command.match.every((part, index) => args[index] === part);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runPassthrough(config, command, args) {
|
|
119
|
+
const binary = ensureBinary(config);
|
|
120
|
+
if (!binary.ok) return 1;
|
|
121
|
+
|
|
122
|
+
const rest = args.slice(command.match.length);
|
|
123
|
+
const cliArgs = [...command.forward, ...rest];
|
|
124
|
+
const result = spawnSync(config.binary, cliArgs, {
|
|
125
|
+
stdio: 'inherit',
|
|
126
|
+
env: process.env,
|
|
127
|
+
});
|
|
128
|
+
if (missingBinary(result)) {
|
|
129
|
+
printMissingBinary(config);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
if (result.error) {
|
|
133
|
+
console.error(`${config.name} command failed: ${result.error.message}`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
return result.status ?? 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createOfficialCliCommand(config) {
|
|
140
|
+
return function officialCliCommand(args = []) {
|
|
141
|
+
const normalizedArgs = Array.isArray(args) ? args : Array.from(arguments);
|
|
142
|
+
if (normalizedArgs.length === 0 || hasHelp(normalizedArgs)) {
|
|
143
|
+
printHelp(config);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const subcommand = normalizedArgs[0];
|
|
148
|
+
const doctorAliases = config.doctorAliases || ['doctor', 'status'];
|
|
149
|
+
if (doctorAliases.includes(subcommand)) {
|
|
150
|
+
return printDoctor(config);
|
|
151
|
+
}
|
|
152
|
+
if (subcommand === 'auth') {
|
|
153
|
+
return printAuth(config);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const command = matchCommand(config, normalizedArgs);
|
|
157
|
+
if (!command) {
|
|
158
|
+
console.error(`unknown ${config.name} command: ${formatCommand(normalizedArgs)}`);
|
|
159
|
+
console.error(`run: atris ${config.name} --help`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return runPassthrough(config, command, normalizedArgs);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
createOfficialCliCommand,
|
|
169
|
+
ensureBinary,
|
|
170
|
+
hasHelp,
|
|
171
|
+
matchCommand,
|
|
172
|
+
printHelp,
|
|
173
|
+
runAuthCheck,
|
|
174
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const {
|
|
7
|
+
HTML_TAG_RE,
|
|
8
|
+
RENDERED_SOURCE_FENCE_RE,
|
|
9
|
+
scanOutboundArtifact,
|
|
10
|
+
} = require('../scripts/outbound-artifact-gate');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_GATE = Object.freeze({
|
|
13
|
+
format: 'plain',
|
|
14
|
+
proofFile: null,
|
|
15
|
+
bodyFile: null,
|
|
16
|
+
visual: false,
|
|
17
|
+
allowSlop: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
class OutboundArtifactGateError extends Error {
|
|
21
|
+
constructor(check, file, errors = []) {
|
|
22
|
+
super(`outbound artifact gate failed: ${check} in ${file}`);
|
|
23
|
+
this.name = 'OutboundArtifactGateError';
|
|
24
|
+
this.check = check;
|
|
25
|
+
this.file = file;
|
|
26
|
+
this.errors = errors;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultGateOptions() {
|
|
31
|
+
return { ...DEFAULT_GATE };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readOutboundBodyFile(bodyFile) {
|
|
35
|
+
const file = path.resolve(String(bodyFile || ''));
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync(file, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
throw new OutboundArtifactGateError('body-file-read-error', file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeInlineBodyFile(body) {
|
|
44
|
+
const file = path.join(os.tmpdir(), `atris-outbound-gate-${process.pid}-${Date.now()}.txt`);
|
|
45
|
+
fs.writeFileSync(file, String(body || ''), { encoding: 'utf8', flag: 'wx' });
|
|
46
|
+
return file;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeProofFile(proofFile) {
|
|
50
|
+
return proofFile ? path.resolve(String(proofFile)) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractOutboundGateOptions(args = []) {
|
|
54
|
+
const rest = [];
|
|
55
|
+
const gate = defaultGateOptions();
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
58
|
+
const arg = String(args[i] || '');
|
|
59
|
+
const eq = arg.indexOf('=');
|
|
60
|
+
const key = eq >= 0 ? arg.slice(0, eq) : arg;
|
|
61
|
+
const inlineValue = eq >= 0 ? arg.slice(eq + 1) : null;
|
|
62
|
+
|
|
63
|
+
if (key === '--visual') {
|
|
64
|
+
gate.visual = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (key === '--allow-slop') {
|
|
68
|
+
gate.allowSlop = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (key === '--format') {
|
|
72
|
+
gate.format = String(inlineValue !== null ? inlineValue : args[i + 1] || 'plain').toLowerCase();
|
|
73
|
+
if (inlineValue === null) i += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (key === '--proof-file' || key === '--render-proof') {
|
|
77
|
+
gate.proofFile = normalizeProofFile(inlineValue !== null ? inlineValue : args[i + 1]);
|
|
78
|
+
if (inlineValue === null) i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (key === '--body-file') {
|
|
82
|
+
gate.bodyFile = path.resolve(String(inlineValue !== null ? inlineValue : args[i + 1] || ''));
|
|
83
|
+
if (inlineValue === null) i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
rest.push(arg);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { args: rest, gate };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseOutboundSendArgs(args = []) {
|
|
94
|
+
const parsed = extractOutboundGateOptions(args);
|
|
95
|
+
return {
|
|
96
|
+
text: parsed.gate.bodyFile ? '' : parsed.args.join(' ').trim(),
|
|
97
|
+
gate: parsed.gate,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shouldRunOutboundGate(channel, body, gate = {}) {
|
|
102
|
+
const format = String(gate.format || 'plain').toLowerCase();
|
|
103
|
+
if (format !== 'plain') return true;
|
|
104
|
+
if (gate.visual || gate.proofFile || gate.bodyFile) return true;
|
|
105
|
+
return HTML_TAG_RE.test(body) || RENDERED_SOURCE_FENCE_RE.test(body);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertOutboundSendAllowed(channel, body, gate = {}) {
|
|
109
|
+
const options = { ...defaultGateOptions(), ...(gate || {}) };
|
|
110
|
+
options.format = String(options.format || 'plain').toLowerCase();
|
|
111
|
+
options.channel = String(channel || 'other').toLowerCase();
|
|
112
|
+
options.proofFile = normalizeProofFile(options.proofFile);
|
|
113
|
+
if (options.bodyFile) options.bodyFile = path.resolve(String(options.bodyFile));
|
|
114
|
+
|
|
115
|
+
const inspectedBody = options.bodyFile ? readOutboundBodyFile(options.bodyFile) : String(body || '');
|
|
116
|
+
if (!shouldRunOutboundGate(options.channel, inspectedBody, options)) {
|
|
117
|
+
return { ok: true, inspected: false, body: inspectedBody, file: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const inspectedFile = options.bodyFile || writeInlineBodyFile(inspectedBody);
|
|
121
|
+
const errors = scanOutboundArtifact({
|
|
122
|
+
channel: options.channel,
|
|
123
|
+
format: options.format,
|
|
124
|
+
bodyFile: inspectedFile,
|
|
125
|
+
proofFile: options.proofFile,
|
|
126
|
+
visual: options.visual,
|
|
127
|
+
allowSlop: options.allowSlop,
|
|
128
|
+
}, inspectedBody);
|
|
129
|
+
|
|
130
|
+
if (errors.length) {
|
|
131
|
+
throw new OutboundArtifactGateError(errors[0].id, inspectedFile, errors);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { ok: true, inspected: true, body: inspectedBody, file: inspectedFile };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function exitOutboundGateFailure(error) {
|
|
138
|
+
console.error(error.message);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertOutboundSendAllowedOrExit(channel, body, gate = {}, options = {}) {
|
|
143
|
+
try {
|
|
144
|
+
return assertOutboundSendAllowed(channel, body, gate);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
exitOutboundGateFailure(error, options);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function prepareOutboundSendTextOrExit(channel, args = [], options = {}) {
|
|
151
|
+
const parsed = parseOutboundSendArgs(args);
|
|
152
|
+
return assertOutboundSendAllowedOrExit(channel, parsed.text, parsed.gate, options);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
OutboundArtifactGateError,
|
|
157
|
+
assertOutboundSendAllowed,
|
|
158
|
+
assertOutboundSendAllowedOrExit,
|
|
159
|
+
defaultGateOptions,
|
|
160
|
+
extractOutboundGateOptions,
|
|
161
|
+
parseOutboundSendArgs,
|
|
162
|
+
prepareOutboundSendTextOrExit,
|
|
163
|
+
readOutboundBodyFile,
|
|
164
|
+
shouldRunOutboundGate,
|
|
165
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Persistent permission grants (CLI-831).
|
|
4
|
+
// A grant lets an exact approved command pattern auto-redeem its workspace
|
|
5
|
+
// approval instead of re-asking. syncGrants() pushes the local store to the
|
|
6
|
+
// backend and pulls back grants created or revoked from atrisos-web, so the
|
|
7
|
+
// same grants can be reviewed and revoked from the web.
|
|
8
|
+
// Design brief: atris/designs/permission-grants-cli-831.md
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
const GRANTS_SCHEMA = 'atris.permission_grants.v1';
|
|
16
|
+
|
|
17
|
+
// Backend reconciliation endpoint. The CLI POSTs its local grants and the
|
|
18
|
+
// backend echoes the authoritative set (including web-created/revoked grants).
|
|
19
|
+
const GRANTS_SYNC_ENDPOINT = '/workspace/permission-grants/sync';
|
|
20
|
+
|
|
21
|
+
// Shell metacharacters change the meaning of a command after matching, so a
|
|
22
|
+
// command containing any of them is never grantable and never matchable.
|
|
23
|
+
const SHELL_META = /[;&|<>`$(){}\[\]*?~\\\n\r]|\r|\n/;
|
|
24
|
+
|
|
25
|
+
// Commands that must always re-ask, no matter what the operator granted.
|
|
26
|
+
const NEVER_GRANTABLE = [
|
|
27
|
+
/^sudo\b/,
|
|
28
|
+
/\brm\s+(-\w*[rf]\w*\s+)+/,
|
|
29
|
+
/\bgit\s+push\s+.*--force/,
|
|
30
|
+
/\bgit\s+reset\s+--hard/,
|
|
31
|
+
/\b(sh|bash|zsh)\s+-c\b/,
|
|
32
|
+
/\bnode\s+-e\b/,
|
|
33
|
+
/\bpython3?\s+-c\b/,
|
|
34
|
+
/\bchmod\b|\bchown\b/,
|
|
35
|
+
/credentials\.json|\.ssh\b|\.env\b/,
|
|
36
|
+
/\batris\s+task\s+accept\b/,
|
|
37
|
+
/\bautoland\b.*polic/,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function grantsFilePath() {
|
|
41
|
+
return process.env.ATRIS_PERMISSION_GRANTS_FILE
|
|
42
|
+
|| path.join(os.homedir(), '.atris', 'permission-grants.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseArgv(command) {
|
|
46
|
+
const text = String(command || '').trim();
|
|
47
|
+
if (!text || SHELL_META.test(text)) return null;
|
|
48
|
+
const argv = text.split(/\s+/).filter(Boolean);
|
|
49
|
+
return argv.length ? argv : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commandIsGrantable(command) {
|
|
53
|
+
const text = String(command || '').trim();
|
|
54
|
+
if (!text) return { ok: false, reason: 'empty command' };
|
|
55
|
+
if (SHELL_META.test(text)) return { ok: false, reason: 'shell metacharacters are never grantable' };
|
|
56
|
+
for (const rule of NEVER_GRANTABLE) {
|
|
57
|
+
if (rule.test(text)) return { ok: false, reason: `never grantable: matches ${rule}` };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadGrants(file = grantsFilePath()) {
|
|
63
|
+
try {
|
|
64
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
65
|
+
if (data && data.schema === GRANTS_SCHEMA && Array.isArray(data.grants)) return data;
|
|
66
|
+
} catch {
|
|
67
|
+
// Missing or unreadable store means no grants; fail closed to re-asking.
|
|
68
|
+
}
|
|
69
|
+
return { schema: GRANTS_SCHEMA, grants: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function saveGrants(store, file = grantsFilePath()) {
|
|
73
|
+
const dir = path.dirname(file);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
76
|
+
fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
fs.renameSync(tmp, file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function canonicalRoot(workspaceRoot) {
|
|
81
|
+
try {
|
|
82
|
+
return fs.realpathSync(String(workspaceRoot || ''));
|
|
83
|
+
} catch {
|
|
84
|
+
return String(workspaceRoot || '');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function addGrant({ command, workspaceRoot, createdVia = 'cli', expiresInDays = 30, file } = {}) {
|
|
89
|
+
const grantable = commandIsGrantable(command);
|
|
90
|
+
if (!grantable.ok) return { ok: false, reason: grantable.reason };
|
|
91
|
+
const argv = parseArgv(command);
|
|
92
|
+
if (!argv) return { ok: false, reason: 'command does not parse to a plain argv' };
|
|
93
|
+
const root = canonicalRoot(workspaceRoot);
|
|
94
|
+
if (!root || root === '/') return { ok: false, reason: 'grants must be scoped to a workspace root' };
|
|
95
|
+
const store = loadGrants(file);
|
|
96
|
+
const now = new Date();
|
|
97
|
+
const grant = {
|
|
98
|
+
grant_id: `grant-${crypto.randomBytes(6).toString('hex')}`,
|
|
99
|
+
status: 'active',
|
|
100
|
+
scope: { kind: 'workspace', workspace_root: root },
|
|
101
|
+
principal: { created_via: createdVia },
|
|
102
|
+
action_type: 'local_command',
|
|
103
|
+
pattern: {
|
|
104
|
+
type: 'exact_argv',
|
|
105
|
+
argv,
|
|
106
|
+
display: argv.join(' '),
|
|
107
|
+
normalized_hash: crypto.createHash('sha256').update(argv.join(' ')).digest('hex'),
|
|
108
|
+
},
|
|
109
|
+
constraints: {
|
|
110
|
+
expires_at: new Date(now.getTime() + expiresInDays * 24 * 60 * 60 * 1000).toISOString(),
|
|
111
|
+
cwd_must_be_within_workspace: true,
|
|
112
|
+
},
|
|
113
|
+
audit: { created_at: now.toISOString(), last_used_at: null, use_count: 0 },
|
|
114
|
+
};
|
|
115
|
+
store.grants.push(grant);
|
|
116
|
+
saveGrants(store, file);
|
|
117
|
+
return { ok: true, grant };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function revokeGrant(grantId, file) {
|
|
121
|
+
const store = loadGrants(file);
|
|
122
|
+
const grant = store.grants.find(g => g.grant_id === grantId || String(g.grant_id).startsWith(String(grantId)));
|
|
123
|
+
if (!grant) return { ok: false, reason: 'grant not found' };
|
|
124
|
+
grant.status = 'revoked';
|
|
125
|
+
grant.sync = { ...(grant.sync || {}), revoked_at: new Date().toISOString() };
|
|
126
|
+
saveGrants(store, file);
|
|
127
|
+
return { ok: true, grant };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function matchGrant({ command, workspaceRoot, now = new Date(), file } = {}) {
|
|
131
|
+
const argv = parseArgv(command);
|
|
132
|
+
if (!argv) return null;
|
|
133
|
+
if (!commandIsGrantable(command).ok) return null;
|
|
134
|
+
const root = canonicalRoot(workspaceRoot);
|
|
135
|
+
const store = loadGrants(file);
|
|
136
|
+
for (const grant of store.grants) {
|
|
137
|
+
if (grant.status !== 'active') continue;
|
|
138
|
+
if (grant.action_type !== 'local_command') continue;
|
|
139
|
+
if (!grant.scope || grant.scope.workspace_root !== root) continue;
|
|
140
|
+
const expiresAt = grant.constraints && grant.constraints.expires_at;
|
|
141
|
+
if (expiresAt && new Date(expiresAt).getTime() <= now.getTime()) continue;
|
|
142
|
+
const maxUses = grant.constraints && grant.constraints.max_uses;
|
|
143
|
+
if (maxUses && (grant.audit?.use_count || 0) >= maxUses) continue;
|
|
144
|
+
const pattern = grant.pattern || {};
|
|
145
|
+
if (pattern.type === 'exact_argv') {
|
|
146
|
+
if (Array.isArray(pattern.argv)
|
|
147
|
+
&& pattern.argv.length === argv.length
|
|
148
|
+
&& pattern.argv.every((token, i) => token === argv[i])) return grant;
|
|
149
|
+
} else if (pattern.type === 'argv_prefix') {
|
|
150
|
+
if (Array.isArray(pattern.argv)
|
|
151
|
+
&& pattern.argv.length <= argv.length
|
|
152
|
+
&& pattern.argv.every((token, i) => token === argv[i])) return grant;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function recordUse(grantId, file) {
|
|
159
|
+
const store = loadGrants(file);
|
|
160
|
+
const grant = store.grants.find(g => g.grant_id === grantId);
|
|
161
|
+
if (!grant) return null;
|
|
162
|
+
grant.audit = grant.audit || {};
|
|
163
|
+
grant.audit.use_count = (grant.audit.use_count || 0) + 1;
|
|
164
|
+
grant.audit.last_used_at = new Date().toISOString();
|
|
165
|
+
saveGrants(store, file);
|
|
166
|
+
return grant;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A grant pulled from the backend is only trusted if it would itself have been
|
|
170
|
+
// grantable locally. This is the fail-closed seam for sync: a compromised or
|
|
171
|
+
// buggy backend can never inject an auto-approvable dangerous pattern, because
|
|
172
|
+
// its command must survive the same NEVER_GRANTABLE / metacharacter gate that
|
|
173
|
+
// addGrant() applies. Malformed or non-command grants are dropped.
|
|
174
|
+
function normalizeRemoteGrant(remote) {
|
|
175
|
+
if (!remote || typeof remote !== 'object') return null;
|
|
176
|
+
if (String(remote.action_type || '') !== 'local_command') return null;
|
|
177
|
+
const grantId = String(remote.grant_id || '');
|
|
178
|
+
if (!grantId) return null;
|
|
179
|
+
const pattern = remote.pattern || {};
|
|
180
|
+
const type = pattern.type;
|
|
181
|
+
if (type !== 'exact_argv' && type !== 'argv_prefix') return null;
|
|
182
|
+
if (!Array.isArray(pattern.argv) || !pattern.argv.length) return null;
|
|
183
|
+
if (!pattern.argv.every(token => typeof token === 'string' && token.length)) return null;
|
|
184
|
+
const display = typeof pattern.display === 'string' && pattern.display
|
|
185
|
+
? pattern.display
|
|
186
|
+
: pattern.argv.join(' ');
|
|
187
|
+
if (!commandIsGrantable(display).ok) return null;
|
|
188
|
+
const root = String((remote.scope && remote.scope.workspace_root) || '');
|
|
189
|
+
if (!root || root === '/') return null;
|
|
190
|
+
return {
|
|
191
|
+
grant_id: grantId,
|
|
192
|
+
status: remote.status === 'revoked' ? 'revoked' : 'active',
|
|
193
|
+
scope: { kind: 'workspace', workspace_root: root },
|
|
194
|
+
principal: (remote.principal && typeof remote.principal === 'object')
|
|
195
|
+
? remote.principal
|
|
196
|
+
: { created_via: 'web' },
|
|
197
|
+
action_type: 'local_command',
|
|
198
|
+
pattern: {
|
|
199
|
+
type,
|
|
200
|
+
argv: pattern.argv.slice(),
|
|
201
|
+
display,
|
|
202
|
+
normalized_hash: crypto.createHash('sha256').update(pattern.argv.join(' ')).digest('hex'),
|
|
203
|
+
},
|
|
204
|
+
constraints: (remote.constraints && typeof remote.constraints === 'object')
|
|
205
|
+
? remote.constraints
|
|
206
|
+
: {},
|
|
207
|
+
audit: (remote.audit && typeof remote.audit === 'object')
|
|
208
|
+
? remote.audit
|
|
209
|
+
: { created_at: null, last_used_at: null, use_count: 0 },
|
|
210
|
+
sync: (remote.sync && typeof remote.sync === 'object')
|
|
211
|
+
? remote.sync
|
|
212
|
+
: { source: 'web' },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Merge the backend's authoritative grants into the local store in place.
|
|
217
|
+
// - New grants (created/managed from web) are pulled down so the CLI can redeem
|
|
218
|
+
// them locally.
|
|
219
|
+
// - Revocation always wins: a grant revoked on the web is revoked locally, and a
|
|
220
|
+
// grant we already revoked is never resurrected. Permission state fails closed.
|
|
221
|
+
function mergeRemoteGrants(store, remoteGrants = [], now = new Date()) {
|
|
222
|
+
const byId = new Map(store.grants.map(g => [g.grant_id, g]));
|
|
223
|
+
let pulled = 0;
|
|
224
|
+
let revoked = 0;
|
|
225
|
+
let rejected = 0;
|
|
226
|
+
for (const raw of Array.isArray(remoteGrants) ? remoteGrants : []) {
|
|
227
|
+
const remote = normalizeRemoteGrant(raw);
|
|
228
|
+
if (!remote) { rejected += 1; continue; }
|
|
229
|
+
const local = byId.get(remote.grant_id);
|
|
230
|
+
if (!local) {
|
|
231
|
+
store.grants.push(remote);
|
|
232
|
+
byId.set(remote.grant_id, remote);
|
|
233
|
+
if (remote.status === 'revoked') revoked += 1;
|
|
234
|
+
else pulled += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (remote.status === 'revoked' && local.status !== 'revoked') {
|
|
238
|
+
local.status = 'revoked';
|
|
239
|
+
local.sync = {
|
|
240
|
+
...(local.sync || {}),
|
|
241
|
+
revoked_at: (remote.sync && remote.sync.revoked_at) || now.toISOString(),
|
|
242
|
+
revoked_by: 'web',
|
|
243
|
+
};
|
|
244
|
+
revoked += 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { pulled, revoked, rejected };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Push the local grants to the backend and merge back the authoritative set.
|
|
251
|
+
// apiRequestJson is injected (utils/api) so this stays offline-testable. The
|
|
252
|
+
// call is best-effort: on any transport/HTTP failure the local store is left
|
|
253
|
+
// untouched and { ok: false, reason } is returned — grants keep working locally.
|
|
254
|
+
async function syncGrants({ apiRequestJson, token, file, endpoint = GRANTS_SYNC_ENDPOINT, now = new Date() } = {}) {
|
|
255
|
+
if (typeof apiRequestJson !== 'function') {
|
|
256
|
+
return { ok: false, reason: 'no api client' };
|
|
257
|
+
}
|
|
258
|
+
const store = loadGrants(file);
|
|
259
|
+
let response;
|
|
260
|
+
try {
|
|
261
|
+
response = await apiRequestJson(endpoint, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
token,
|
|
264
|
+
body: { schema: GRANTS_SCHEMA, grants: store.grants },
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return { ok: false, reason: (error && error.message) || 'sync request failed', pushed: store.grants.length };
|
|
268
|
+
}
|
|
269
|
+
if (!response || !response.ok) {
|
|
270
|
+
return { ok: false, reason: (response && response.error) || 'sync request failed', pushed: store.grants.length };
|
|
271
|
+
}
|
|
272
|
+
const remoteGrants = (response.data && Array.isArray(response.data.grants)) ? response.data.grants : [];
|
|
273
|
+
const merged = mergeRemoteGrants(store, remoteGrants, now);
|
|
274
|
+
saveGrants(store, file);
|
|
275
|
+
return { ok: true, pushed: store.grants.length, ...merged };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
GRANTS_SCHEMA,
|
|
280
|
+
GRANTS_SYNC_ENDPOINT,
|
|
281
|
+
grantsFilePath,
|
|
282
|
+
parseArgv,
|
|
283
|
+
commandIsGrantable,
|
|
284
|
+
loadGrants,
|
|
285
|
+
saveGrants,
|
|
286
|
+
addGrant,
|
|
287
|
+
revokeGrant,
|
|
288
|
+
matchGrant,
|
|
289
|
+
recordUse,
|
|
290
|
+
normalizeRemoteGrant,
|
|
291
|
+
mergeRemoteGrants,
|
|
292
|
+
syncGrants,
|
|
293
|
+
};
|