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.
Files changed (53) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. 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
+ };