atris 3.33.3 → 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 +19 -0
- package/ax +475 -17
- package/bin/atris.js +206 -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/loops.js +212 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +598 -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 +256 -13
- 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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// judge != worker: the builder of a task cannot be the actor whose review
|
|
4
|
+
// pass certifies or lands it. This module is the single home for actor
|
|
5
|
+
// identity rules; task-db, auto-accept, and the task CLI all lean on it.
|
|
6
|
+
// Level 1 trust: normalized string identity + builder exclusion + reserved
|
|
7
|
+
// system names + roster validation (warn by default). It stops accidental
|
|
8
|
+
// and lazy self-judging and makes deliberate spoofing auditable; signed
|
|
9
|
+
// events (level 2) are a follow-up, not this file.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const functionalOwner = require('./functional-owner');
|
|
15
|
+
|
|
16
|
+
// System actors written by the machinery itself; a human or agent may never
|
|
17
|
+
// claim/ready/review under these names.
|
|
18
|
+
const RESERVED_ACTORS = new Set(['autoland-verifier', 'auto-accept-certified']);
|
|
19
|
+
|
|
20
|
+
function normalizeActor(value) {
|
|
21
|
+
return String(value || '')
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function envActor() {
|
|
29
|
+
return normalizeActor(process.env.ATRIS_AGENT_ID || process.env.USER || '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Builder identity, oldest signal wins: explicit stamp, then who claimed it,
|
|
33
|
+
// then whoever sent the first proof.
|
|
34
|
+
function taskBuilder(task) {
|
|
35
|
+
if (!task || typeof task !== 'object') return null;
|
|
36
|
+
const metadata = task.metadata || {};
|
|
37
|
+
const stamped = normalizeActor(metadata.built_by);
|
|
38
|
+
if (stamped) return stamped;
|
|
39
|
+
const claimed = normalizeActor(task.claimed_by);
|
|
40
|
+
if (claimed) return claimed;
|
|
41
|
+
for (const event of task.events || []) {
|
|
42
|
+
if (event && event.event_type === 'proof_ready') {
|
|
43
|
+
const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
|
|
44
|
+
if (actor) return actor;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reviewEventActors(task) {
|
|
51
|
+
const actors = new Set();
|
|
52
|
+
for (const event of (task && task.events) || []) {
|
|
53
|
+
if (!event || !['proof_ready', 'reviewed'].includes(event.event_type)) continue;
|
|
54
|
+
const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
|
|
55
|
+
if (actor) actors.add(actor);
|
|
56
|
+
}
|
|
57
|
+
return actors;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Actors who reviewed the task and are not its builder.
|
|
61
|
+
function independentReviewActors(task) {
|
|
62
|
+
const builder = taskBuilder(task);
|
|
63
|
+
const actors = reviewEventActors(task);
|
|
64
|
+
if (builder) actors.delete(builder);
|
|
65
|
+
return actors;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// True when at least one review pass came from someone other than the
|
|
69
|
+
// builder. When the builder cannot be resolved at all, fall back to
|
|
70
|
+
// requiring two distinct actors — independence cannot be proven from one.
|
|
71
|
+
function hasIndependentReview(task) {
|
|
72
|
+
const builder = taskBuilder(task);
|
|
73
|
+
if (!builder) return reviewEventActors(task).size >= 2;
|
|
74
|
+
return independentReviewActors(task).size >= 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isReservedActor(actor) {
|
|
78
|
+
return RESERVED_ACTORS.has(normalizeActor(actor));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rosterActors(root) {
|
|
82
|
+
const actors = new Set(functionalOwner.listWorkspaceMemberSlugs(root));
|
|
83
|
+
for (const id of ['atris-2-fast', 'atris2', 'atris2-fast', 'claude', 'codex', 'codex-executor', 'cursor', 'devin', 'executor', 'openclaw', 'windsurf']) {
|
|
84
|
+
actors.add(id);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const user = normalizeActor(os.userInfo().username);
|
|
88
|
+
if (user) actors.add(user);
|
|
89
|
+
} catch (_) { /* identity lookup can fail in stripped containers */ }
|
|
90
|
+
const envUser = normalizeActor(process.env.USER);
|
|
91
|
+
if (envUser) actors.add(envUser);
|
|
92
|
+
const agentId = normalizeActor(process.env.ATRIS_AGENT_ID);
|
|
93
|
+
if (agentId) actors.add(agentId);
|
|
94
|
+
try {
|
|
95
|
+
const policyPath = path.join(root || process.cwd(), '.atris', 'policy', 'autoland.json');
|
|
96
|
+
const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
97
|
+
const enabledBy = normalizeActor(policy.enabled_by);
|
|
98
|
+
if (enabledBy) actors.add(enabledBy);
|
|
99
|
+
} catch (_) { /* no policy is fine */ }
|
|
100
|
+
return actors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// off | warn | enforce. Env wins, then .atris/review-policy.json, then off.
|
|
104
|
+
// Off by default on purpose: ad-hoc --as names are everywhere in scripts and
|
|
105
|
+
// fixtures, and a warning that fires on legitimate flows teaches everyone to
|
|
106
|
+
// ignore warnings (the cry-wolf lesson). Reserved-actor rejection and
|
|
107
|
+
// builder-exclusion do not depend on this mode and are always enforced.
|
|
108
|
+
function actorValidationMode(root) {
|
|
109
|
+
const env = String(process.env.ATRIS_ACTOR_VALIDATION || '').toLowerCase();
|
|
110
|
+
if (['off', 'warn', 'enforce'].includes(env)) return env;
|
|
111
|
+
try {
|
|
112
|
+
const policyPath = path.join(root || process.cwd(), '.atris', 'review-policy.json');
|
|
113
|
+
const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
114
|
+
const mode = String(policy.actor_validation || '').toLowerCase();
|
|
115
|
+
if (['off', 'warn', 'enforce'].includes(mode)) return mode;
|
|
116
|
+
} catch (_) { /* no policy is fine */ }
|
|
117
|
+
return 'off';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Reserved names are rejected in every mode; unknown names pass in warn mode
|
|
121
|
+
// (with ok true + reason so callers can print) and fail in enforce mode.
|
|
122
|
+
function validateActor(actor, { root } = {}) {
|
|
123
|
+
const normalized = normalizeActor(actor);
|
|
124
|
+
if (!normalized) return { ok: true, mode: 'off', actor: normalized };
|
|
125
|
+
if (isReservedActor(normalized)) {
|
|
126
|
+
return { ok: false, mode: 'enforce', actor: normalized, reason: 'reserved_actor' };
|
|
127
|
+
}
|
|
128
|
+
const mode = actorValidationMode(root);
|
|
129
|
+
if (mode === 'off') return { ok: true, mode, actor: normalized };
|
|
130
|
+
if (rosterActors(root).has(normalized)) return { ok: true, mode, actor: normalized };
|
|
131
|
+
if (mode === 'enforce') return { ok: false, mode, actor: normalized, reason: 'actor_not_on_roster' };
|
|
132
|
+
return { ok: true, mode, actor: normalized, reason: 'actor_not_on_roster' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
RESERVED_ACTORS,
|
|
137
|
+
normalizeActor,
|
|
138
|
+
envActor,
|
|
139
|
+
taskBuilder,
|
|
140
|
+
reviewEventActors,
|
|
141
|
+
independentReviewActors,
|
|
142
|
+
hasIndependentReview,
|
|
143
|
+
isReservedActor,
|
|
144
|
+
rosterActors,
|
|
145
|
+
actorValidationMode,
|
|
146
|
+
validateActor,
|
|
147
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -40,11 +40,33 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
40
40
|
model: '',
|
|
41
41
|
commandTemplate: '{bin} --trust -p {prompt}',
|
|
42
42
|
}),
|
|
43
|
+
// Compatibility names for fleet rosters. These map to real installed
|
|
44
|
+
// runners instead of retired or UI-only model names.
|
|
45
|
+
fable: Object.freeze({
|
|
46
|
+
bin: 'claude',
|
|
47
|
+
model: '',
|
|
48
|
+
commandTemplate: '',
|
|
49
|
+
}),
|
|
50
|
+
composer: Object.freeze({
|
|
51
|
+
bin: 'ax',
|
|
52
|
+
model: 'composer-2-5-fast',
|
|
53
|
+
commandTemplate: '{bin} --fast {prompt}',
|
|
54
|
+
}),
|
|
55
|
+
haiku: Object.freeze({
|
|
56
|
+
bin: 'claude',
|
|
57
|
+
model: 'claude-haiku-4-5',
|
|
58
|
+
commandTemplate: '',
|
|
59
|
+
}),
|
|
43
60
|
devin: Object.freeze({
|
|
44
61
|
bin: 'devin',
|
|
45
62
|
model: '',
|
|
46
63
|
commandTemplate: '{bin} -p -- {prompt}',
|
|
47
64
|
}),
|
|
65
|
+
hermes: Object.freeze({
|
|
66
|
+
bin: 'hermes',
|
|
67
|
+
model: '',
|
|
68
|
+
commandTemplate: '{bin} -p -- {prompt}',
|
|
69
|
+
}),
|
|
48
70
|
});
|
|
49
71
|
|
|
50
72
|
// Alias -> canonical profile name. Every alias resolves to the same config as
|
|
@@ -53,6 +75,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
53
75
|
const RUNNER_PROFILE_ALIASES = Object.freeze({
|
|
54
76
|
'atris2-fast': 'atris-fast',
|
|
55
77
|
'atris-2-fast': 'atris-fast',
|
|
78
|
+
'hermes-agent': 'hermes',
|
|
56
79
|
});
|
|
57
80
|
|
|
58
81
|
// Back-compat surface: RUNNER_PROFILES still resolves every accepted name
|