great-cto 3.26.2 → 3.26.4
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.26.
|
|
4
|
+
"version": "3.26.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { postureOf } from '../../../scripts/lib/agent-posture.mjs';
|
|
3
4
|
import os from 'os';
|
|
4
5
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
5
6
|
import { readFileSafe } from './util.mjs';
|
|
@@ -128,12 +129,34 @@ function getAgentsFleet(projectCwd) {
|
|
|
128
129
|
const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
|
|
129
130
|
const DEFAULT_TASK_MIN = 30;
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The agent's tool grant, in the language of consequence.
|
|
134
|
+
*
|
|
135
|
+
* FOUR states, because three of them are not "no grant": an unreadable agent
|
|
136
|
+
* file, a file with no `tools:` line, a grant that is entirely routine, and a
|
|
137
|
+
* grant that holds something expensive to undo. The board rendered all of them
|
|
138
|
+
* identically before, because it never saw any of them.
|
|
139
|
+
*/
|
|
140
|
+
function posture(toolsLine, fileReadable) {
|
|
141
|
+
if (!fileReadable) return { state: 'unreadable', expensive: [], scopedInNameOnly: [], why: 'the agent file could not be read' };
|
|
142
|
+
if (toolsLine == null) return { state: 'undeclared', expensive: [], scopedInNameOnly: [], why: 'the agent declares no tools: line' };
|
|
143
|
+
const r = postureOf(toolsLine);
|
|
144
|
+
return {
|
|
145
|
+
state: r.unknownTools.length ? 'unclassified' : r.expensive.length ? 'expensive' : 'routine',
|
|
146
|
+
expensive: r.expensive,
|
|
147
|
+
scopedInNameOnly: r.scopedInNameOnly,
|
|
148
|
+
unknownTools: r.unknownTools,
|
|
149
|
+
why: r.unknownTools.length ? `unclassified grant(s): ${r.unknownTools.join(', ')}` : '',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
for (const f of files) {
|
|
132
154
|
const slug = f.replace(/^great_cto-/, '').replace(/\.md$/, '');
|
|
133
155
|
const fp = path.join(AGENTS_DIR, f);
|
|
134
156
|
const raw = readFileSafe(fp) || '';
|
|
135
157
|
const descM = raw.match(/^description:\s*"?([^"\n]+)"?/m);
|
|
136
158
|
const modelM = raw.match(/^model:\s*(\S+)/m);
|
|
159
|
+
const toolsM = raw.match(/^tools:\s*(.*)$/m);
|
|
137
160
|
const colorM = raw.match(/^color:\s*(\S+)/m);
|
|
138
161
|
|
|
139
162
|
const vs = byAgent.get(slug) || [];
|
|
@@ -155,6 +178,11 @@ function getAgentsFleet(projectCwd) {
|
|
|
155
178
|
// Estimated cost — DEFAULT_TASK_MIN per verdict (no real timing data here).
|
|
156
179
|
const estLlmUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * LLM_RATE_PER_HR;
|
|
157
180
|
const estHumanUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * HUMAN_RATE_PER_HR;
|
|
181
|
+
// NOT a measurement. Both sides are runs x DEFAULT_TASK_MIN x a rate, so this
|
|
182
|
+
// ratio is HUMAN_RATE/LLM_RATE for every agent that ran at all — 500 by
|
|
183
|
+
// construction. metrics.mjs nulls its equivalent for exactly this reason;
|
|
184
|
+
// this one shipped as a per-agent number and read like one. Kept, because
|
|
185
|
+
// removing a field breaks the board, but labelled at the source.
|
|
158
186
|
const savingsX = estLlmUsd > 0 ? Math.round(estHumanUsd / estLlmUsd) : null;
|
|
159
187
|
const realLlmUsd = vs30d.reduce((s, v) => s + (v.cost_usd || 0), 0);
|
|
160
188
|
|
|
@@ -167,7 +195,18 @@ function getAgentsFleet(projectCwd) {
|
|
|
167
195
|
agents.push({
|
|
168
196
|
slug,
|
|
169
197
|
description: descM?.[1]?.trim() || '',
|
|
170
|
-
model: modelM
|
|
198
|
+
// THREE states, not a default. `model: modelM || 'sonnet'` reported every
|
|
199
|
+
// agent with no `model:` line as pinned to sonnet, so "pinned to sonnet"
|
|
200
|
+
// and "not pinned at all" were the same string — and an unreadable agent
|
|
201
|
+
// file produced the same answer a third time. The fleet cannot show what
|
|
202
|
+
// it cannot distinguish.
|
|
203
|
+
model: raw ? (modelM?.[1]?.trim() ?? null) : null,
|
|
204
|
+
model_state: !raw ? 'unreadable' : modelM ? 'pinned' : 'undeclared',
|
|
205
|
+
// The tool grant, named in the language of consequence rather than listed.
|
|
206
|
+
// `/api/agents-installed` did not read `tools:` at all, so the board had
|
|
207
|
+
// nowhere to get it — scripts/lib/agent-posture.mjs has classified these
|
|
208
|
+
// since 3.24.0 and nothing was consuming it.
|
|
209
|
+
posture: posture(toolsM?.[1] ?? null, Boolean(raw)),
|
|
171
210
|
color: colorM?.[1]?.trim() || null,
|
|
172
211
|
domain: deriveDomain(slug),
|
|
173
212
|
runs_total: vs.length,
|
|
@@ -182,6 +221,9 @@ function getAgentsFleet(projectCwd) {
|
|
|
182
221
|
human_usd_30d_est: Math.round(estHumanUsd),
|
|
183
222
|
llm_usd_30d_real: realLlmUsd > 0 ? Math.round(realLlmUsd * 100) / 100 : null,
|
|
184
223
|
savings_x: savingsX,
|
|
224
|
+
// 'ratio' = the rate ratio, identical for every agent. 'measured' would
|
|
225
|
+
// require per-run timing, which no path produces today.
|
|
226
|
+
savings_source: savingsX == null ? null : 'ratio',
|
|
185
227
|
health,
|
|
186
228
|
retired: isRetired(slug),
|
|
187
229
|
// Four states, and only `exceeded` can hold a dispatch — see
|
|
@@ -1518,14 +1518,26 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1518
1518
|
const tasks = getTasks(cwd);
|
|
1519
1519
|
const nowMs = Date.now();
|
|
1520
1520
|
const STUCK_H = 48;
|
|
1521
|
-
|
|
1522
|
-
|
|
1521
|
+
// `stuck` was ALWAYS EMPTY and had been since it was written. It read
|
|
1522
|
+
// `t.startedAt`, a field no code path in this repository produces — a task
|
|
1523
|
+
// carries created_at / updated_at / closed_at and nothing else. Every row
|
|
1524
|
+
// got `age_h: null` and was removed by the filter below, so the panel
|
|
1525
|
+
// reported "nothing is stuck" about a question it never asked. There are
|
|
1526
|
+
// seven in-progress tasks here as this is written.
|
|
1527
|
+
//
|
|
1528
|
+
// `updated_at` is the honest proxy: in progress, and unchanged for STUCK_H.
|
|
1529
|
+
// A task whose age cannot be determined is COUNTED, not dropped — an
|
|
1530
|
+
// unmeasurable task is not a healthy one.
|
|
1531
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1532
|
+
let stuckUnmeasurable = 0;
|
|
1533
|
+
const stuck = inProgress
|
|
1523
1534
|
.map(t => {
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1535
|
+
const since = t.updated_at || t.created_at || null;
|
|
1536
|
+
const ms = since ? new Date(since).getTime() : NaN;
|
|
1537
|
+
if (!Number.isFinite(ms)) { stuckUnmeasurable += 1; return null; }
|
|
1538
|
+
return { id: t.id, title: t.title, agent: t.agent, age_h: Math.round((nowMs - ms) / 3600000), since };
|
|
1527
1539
|
})
|
|
1528
|
-
.filter(t => t
|
|
1540
|
+
.filter(t => t && t.age_h > STUCK_H);
|
|
1529
1541
|
|
|
1530
1542
|
// Per-agent budgets from PROJECT.md
|
|
1531
1543
|
const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
|
|
@@ -1583,7 +1595,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1583
1595
|
// line the parser could not read is reported rather than dropped, because a
|
|
1584
1596
|
// budget silently ignored is a limit its author believes they have.
|
|
1585
1597
|
res.end(JSON.stringify({
|
|
1586
|
-
stuck,
|
|
1598
|
+
stuck, stuck_in_progress: inProgress.length, stuck_unmeasurable: stuckUnmeasurable,
|
|
1599
|
+
budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
|
|
1587
1600
|
budgets_deprecated_key: budgetsDeprecatedKey,
|
|
1588
1601
|
budgets_malformed: budgetsMalformed,
|
|
1589
1602
|
// Three states, not two: read / absent / unreadable. Without these, a
|
|
@@ -577,6 +577,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
577
577
|
}
|
|
578
578
|
.live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--status-review); }
|
|
579
579
|
.live-dot.error { background: var(--status-blocked); }
|
|
580
|
+
/* Neither live nor failed: the handshake has not resolved. Three states. */
|
|
581
|
+
.live-dot.connecting { background: var(--text3); animation: none; }
|
|
580
582
|
.live-dot.pulse { animation: pulse 2s infinite; }
|
|
581
583
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
582
584
|
|
|
@@ -3131,8 +3133,12 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3131
3133
|
</div>
|
|
3132
3134
|
</nav>
|
|
3133
3135
|
<div class="sidebar-footer">
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
+
<!-- Ships in the CONNECTING state. This markup used to say "live · synced
|
|
3137
|
+
just now" before any connection existed, so from first paint until the
|
|
3138
|
+
SSE handshake resolved — or forever, if neither handler ever fired —
|
|
3139
|
+
the footer asserted a sync that had not happened. -->
|
|
3140
|
+
<span class="live-dot connecting" id="live-dot"></span>
|
|
3141
|
+
<span id="live-label">connecting…</span>
|
|
3136
3142
|
</div>
|
|
3137
3143
|
</aside>
|
|
3138
3144
|
|
|
@@ -7206,8 +7212,25 @@ function renderDashboard(m) {
|
|
|
7206
7212
|
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
7207
7213
|
// honest gap, not a zero.
|
|
7208
7214
|
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
|
|
7209
|
-
|
|
7210
|
-
|
|
7215
|
+
// `?? 0` here painted a GREEN ZERO over a question nobody asked. The tile one
|
|
7216
|
+
// line above goes out of its way to avoid exactly this, and these two did not:
|
|
7217
|
+
// a project whose acceptance data is missing showed "0 rework rounds" in
|
|
7218
|
+
// green, and — worse — a project where security has NEVER BEEN SCANNED showed
|
|
7219
|
+
// "0 open security blocks" in green, which is a passing security scan that
|
|
7220
|
+
// did not happen. That is this board's governing defect rendered in its most
|
|
7221
|
+
// reassuring colour.
|
|
7222
|
+
{
|
|
7223
|
+
v: acc.rework_rounds != null ? acc.rework_rounds : absent('uncomputable', 'no accepted work in this window, so rework cannot be counted'),
|
|
7224
|
+
sub: '', label: 'Rework rounds',
|
|
7225
|
+
cls: acc.rework_rounds == null ? '' : acc.rework_rounds ? 'amber' : 'green',
|
|
7226
|
+
},
|
|
7227
|
+
{
|
|
7228
|
+
v: m.security?.blocked != null ? m.security.blocked : absent('uncomputable', 'no security scan has run — this is not a clean result'),
|
|
7229
|
+
sub: '', label: 'Open security blocks',
|
|
7230
|
+
// No colour when unmeasured. Green would say "safe"; red would say
|
|
7231
|
+
// "unsafe"; neither is known.
|
|
7232
|
+
cls: m.security?.blocked == null ? '' : m.security.blocked ? 'red' : 'green',
|
|
7233
|
+
},
|
|
7211
7234
|
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
7212
7235
|
];
|
|
7213
7236
|
document.getElementById('mp-secondary').innerHTML = secondary.map(s => `
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-posture — what does this agent's tool grant actually LET IT DO?
|
|
3
|
+
*
|
|
4
|
+
* ADR-009 ends with an instruction nobody had a way to follow: "Ask at design
|
|
5
|
+
* time, when the capability is added — not after the incident." The question it
|
|
6
|
+
* asks is about consequence — is this expensive to undo? — while every agent
|
|
7
|
+
* file answers a different question, in a different language: which tools are on
|
|
8
|
+
* the `tools:` line. Reviewing the second does not answer the first. `Bash` and
|
|
9
|
+
* `Bash(node:*)` sit two characters apart and read as careful and careless; in
|
|
10
|
+
* consequence they are the same grant.
|
|
11
|
+
*
|
|
12
|
+
* So this names the grant in the language of the decision. Vocabulary shape
|
|
13
|
+
* borrowed from OpenFirma's capability postures (`credential.read`,
|
|
14
|
+
* `communication.external.send`, `code.destructive`) — the idea only; that
|
|
15
|
+
* project is GPL-3.0 and this one is MIT, so nothing was copied.
|
|
16
|
+
*
|
|
17
|
+
* NOTHING here decides anything, exactly as in `gate-reversibility.mjs`, whose
|
|
18
|
+
* ADR-009 categories it reuses rather than inventing a second vocabulary for the
|
|
19
|
+
* same axis. It classifies, so a reviewer can see the grant they are approving.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CATEGORIES } from './gate-reversibility.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The postures. Each cites the ADR-009 category that makes it expensive, or
|
|
26
|
+
* `null` when the repair is simply to do the thing again.
|
|
27
|
+
*/
|
|
28
|
+
export const POSTURES = Object.freeze({
|
|
29
|
+
'code.read': {
|
|
30
|
+
category: null,
|
|
31
|
+
means: 'read files in the working tree',
|
|
32
|
+
},
|
|
33
|
+
'code.write': {
|
|
34
|
+
category: null,
|
|
35
|
+
means: 'create or modify files — the repair is another edit',
|
|
36
|
+
},
|
|
37
|
+
'code.destructive': {
|
|
38
|
+
category: 'destroys-evidence',
|
|
39
|
+
means: 'delete files, rewrite history, or overwrite work that is not in the index',
|
|
40
|
+
},
|
|
41
|
+
'credential.read': {
|
|
42
|
+
category: 'unrevocable-disclosure',
|
|
43
|
+
means: 'reach secrets on disk or in the environment',
|
|
44
|
+
},
|
|
45
|
+
'communication.external.send': {
|
|
46
|
+
category: 'escapes-the-machine',
|
|
47
|
+
means: 'send data off this machine — a push, a publish, a request body, a URL',
|
|
48
|
+
},
|
|
49
|
+
'network.fetch': {
|
|
50
|
+
category: null,
|
|
51
|
+
means: 'pull from the network; nothing of the user\'s leaves except the request',
|
|
52
|
+
},
|
|
53
|
+
'process.spawn': {
|
|
54
|
+
category: null,
|
|
55
|
+
means: 'start a process or another agent, which then holds its own grant',
|
|
56
|
+
},
|
|
57
|
+
payments: {
|
|
58
|
+
category: 'costs-money',
|
|
59
|
+
means: 'spend money — paid API capacity or provisioned infrastructure',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A shell is a shell. Every posture an unrestricted `Bash` confers, in one list,
|
|
65
|
+
* so the interpreters below cannot drift away from it.
|
|
66
|
+
*/
|
|
67
|
+
const FULL_SHELL = Object.freeze([
|
|
68
|
+
'code.read', 'code.write', 'code.destructive',
|
|
69
|
+
'credential.read', 'communication.external.send', 'network.fetch', 'process.spawn',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bash sub-grants, by the command they scope to.
|
|
74
|
+
*
|
|
75
|
+
* `full: true` marks the ones that scope to a name but not to a capability — an
|
|
76
|
+
* interpreter, or a command that runs other commands. `Bash(node:*)` is
|
|
77
|
+
* `node -e '<anything>'`; `Bash(find:*)` is `find . -exec <anything>`;
|
|
78
|
+
* `Bash(xargs:*)` and `Bash(awk:*)` likewise. These read as restrictions and are
|
|
79
|
+
* not, which is the reason this file exists.
|
|
80
|
+
*/
|
|
81
|
+
const BASH_SCOPES = Object.freeze({
|
|
82
|
+
// Scoped in name only — a full shell wearing a command name.
|
|
83
|
+
node: { full: true, why: 'node -e runs arbitrary JavaScript, including child_process' },
|
|
84
|
+
python3: { full: true, why: 'python3 -c runs arbitrary Python, including os.system' },
|
|
85
|
+
python: { full: true, why: 'python -c runs arbitrary Python, including os.system' },
|
|
86
|
+
xargs: { full: true, why: 'xargs exists to run other commands' },
|
|
87
|
+
find: { full: true, why: 'find -exec and -delete run other commands and remove files' },
|
|
88
|
+
awk: { full: true, why: 'awk has system() and can redirect print into a file' },
|
|
89
|
+
sh: { full: true, why: 'a shell' },
|
|
90
|
+
bash: { full: true, why: 'a shell' },
|
|
91
|
+
zsh: { full: true, why: 'a shell' },
|
|
92
|
+
env: { full: true, why: 'env runs the command that follows it' },
|
|
93
|
+
eval: { full: true, why: 'eval runs the string that follows it' },
|
|
94
|
+
|
|
95
|
+
// Genuinely narrower.
|
|
96
|
+
git: { postures: ['code.read', 'code.write', 'code.destructive', 'communication.external.send'],
|
|
97
|
+
why: 'push sends the tree to a remote; checkout -- and reset --hard destroy uncommitted work' },
|
|
98
|
+
npm: { postures: ['code.read', 'code.write', 'network.fetch', 'communication.external.send', 'process.spawn'],
|
|
99
|
+
why: 'install runs lifecycle scripts; publish escapes the machine' },
|
|
100
|
+
bd: { postures: ['code.read', 'code.write', 'communication.external.send'],
|
|
101
|
+
why: 'bd sync pushes the task store to its remote' },
|
|
102
|
+
cat: { postures: ['code.read', 'credential.read'],
|
|
103
|
+
why: 'the file it reads may be ~/.great_cto/secrets.env' },
|
|
104
|
+
source: { postures: ['code.read', 'credential.read'],
|
|
105
|
+
why: 'sourcing an env file puts its secrets in the environment' },
|
|
106
|
+
sort: { postures: ['code.read', 'code.write'], why: 'sort -o writes' },
|
|
107
|
+
tee: { postures: ['code.read', 'code.write'], why: 'writes what it reads' },
|
|
108
|
+
rm: { postures: ['code.destructive'], why: 'removes files' },
|
|
109
|
+
|
|
110
|
+
ls: { postures: ['code.read'] },
|
|
111
|
+
grep: { postures: ['code.read'] },
|
|
112
|
+
wc: { postures: ['code.read'] },
|
|
113
|
+
head: { postures: ['code.read'] },
|
|
114
|
+
tail: { postures: ['code.read'] },
|
|
115
|
+
date: { postures: ['code.read'] },
|
|
116
|
+
echo: { postures: ['code.read'] },
|
|
117
|
+
printf: { postures: ['code.read'] },
|
|
118
|
+
export: { postures: ['code.read'] },
|
|
119
|
+
mkdir: { postures: ['code.write'] },
|
|
120
|
+
touch: { postures: ['code.write'] },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Non-Bash tools. */
|
|
124
|
+
const TOOL_POSTURES = Object.freeze({
|
|
125
|
+
Read: ['code.read'],
|
|
126
|
+
Glob: ['code.read'],
|
|
127
|
+
Grep: ['code.read'],
|
|
128
|
+
Write: ['code.write'],
|
|
129
|
+
Edit: ['code.write'],
|
|
130
|
+
NotebookEdit: ['code.write'],
|
|
131
|
+
// A URL is a channel. A fetch of `https://evil/?leak=<secret>` is a send, and
|
|
132
|
+
// an agent that can read a file and reach the network can move it.
|
|
133
|
+
WebFetch: ['network.fetch', 'communication.external.send'],
|
|
134
|
+
WebSearch: ['network.fetch', 'communication.external.send'],
|
|
135
|
+
Agent: ['process.spawn'],
|
|
136
|
+
Task: ['process.spawn'],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** MCP and beta tools, matched by prefix — the list of these grows monthly. */
|
|
140
|
+
const PREFIX_POSTURES = Object.freeze([
|
|
141
|
+
[/^advisor_/, ['network.fetch', 'communication.external.send', 'payments']],
|
|
142
|
+
[/^memory_/, ['code.read', 'code.write']],
|
|
143
|
+
[/^mcp__great_cto_llm_router__/, ['network.fetch', 'communication.external.send', 'payments']],
|
|
144
|
+
[/^mcp__grafana__/, ['network.fetch']],
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Split a `tools:` frontmatter value into tokens. `Bash(git:*)` contains a comma
|
|
149
|
+
* in no case we ship, but the split is on commas outside parentheses anyway, so
|
|
150
|
+
* a future `Bash(a:*, b:*)` does not silently become two broken tokens.
|
|
151
|
+
*/
|
|
152
|
+
export function splitTools(line) {
|
|
153
|
+
const out = [];
|
|
154
|
+
let depth = 0; let cur = '';
|
|
155
|
+
for (const ch of String(line ?? '')) {
|
|
156
|
+
if (ch === '(') depth++;
|
|
157
|
+
if (ch === ')') depth--;
|
|
158
|
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
|
159
|
+
cur += ch;
|
|
160
|
+
}
|
|
161
|
+
if (cur.trim()) out.push(cur.trim());
|
|
162
|
+
return out.filter(Boolean);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @returns {{postures:string[], unknown:boolean, fullShell:boolean, why:string}}
|
|
167
|
+
*
|
|
168
|
+
* THREE states for the tool itself, and `unknown` is the one that earns its
|
|
169
|
+
* keep: a tool this table has never heard of grants `unknown`, never nothing.
|
|
170
|
+
* A grant nobody classified must not read as a grant that was classified and
|
|
171
|
+
* found harmless.
|
|
172
|
+
*/
|
|
173
|
+
export function postureOfTool(tool) {
|
|
174
|
+
const t = String(tool ?? '').trim();
|
|
175
|
+
if (!t) return { postures: [], unknown: true, fullShell: false, why: 'no tool given' };
|
|
176
|
+
|
|
177
|
+
if (t === '*' || t === 'All tools') {
|
|
178
|
+
return { postures: [...FULL_SHELL, 'payments'], unknown: false, fullShell: true, why: 'every tool' };
|
|
179
|
+
}
|
|
180
|
+
if (t === 'Bash') {
|
|
181
|
+
return { postures: [...FULL_SHELL], unknown: false, fullShell: true, why: 'unrestricted shell' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const scoped = /^Bash\(([^:)]+)/.exec(t);
|
|
185
|
+
if (scoped) {
|
|
186
|
+
const cmd = scoped[1].trim();
|
|
187
|
+
const hit = BASH_SCOPES[cmd];
|
|
188
|
+
if (!hit) {
|
|
189
|
+
return {
|
|
190
|
+
postures: [], unknown: true, fullShell: false,
|
|
191
|
+
why: `Bash scope '${cmd}' is not in the table — treat as unjudged, not as narrow. Add it to agent-posture.mjs.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (hit.full) {
|
|
195
|
+
return { postures: [...FULL_SHELL], unknown: false, fullShell: true, why: hit.why };
|
|
196
|
+
}
|
|
197
|
+
return { postures: [...hit.postures], unknown: false, fullShell: false, why: hit.why ?? '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (TOOL_POSTURES[t]) {
|
|
201
|
+
return { postures: [...TOOL_POSTURES[t]], unknown: false, fullShell: false, why: '' };
|
|
202
|
+
}
|
|
203
|
+
for (const [re, postures] of PREFIX_POSTURES) {
|
|
204
|
+
if (re.test(t)) return { postures: [...postures], unknown: false, fullShell: false, why: '' };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
postures: [], unknown: true, fullShell: false,
|
|
208
|
+
why: `'${t}' is not in the table — treat as unjudged, not as harmless. Add it to agent-posture.mjs.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The posture of a whole `tools:` line.
|
|
214
|
+
*
|
|
215
|
+
* @returns {{postures:string[], expensive:string[], unknownTools:string[],
|
|
216
|
+
* fullShellVia:string[], scopedInNameOnly:string[]}}
|
|
217
|
+
*/
|
|
218
|
+
export function postureOf(toolsLine) {
|
|
219
|
+
const tools = splitTools(toolsLine);
|
|
220
|
+
const postures = new Set();
|
|
221
|
+
const unknownTools = [];
|
|
222
|
+
const fullShellVia = [];
|
|
223
|
+
const scopedInNameOnly = [];
|
|
224
|
+
|
|
225
|
+
for (const t of tools) {
|
|
226
|
+
const r = postureOfTool(t);
|
|
227
|
+
if (r.unknown) { unknownTools.push(t); continue; }
|
|
228
|
+
for (const p of r.postures) postures.add(p);
|
|
229
|
+
if (r.fullShell) {
|
|
230
|
+
fullShellVia.push(t);
|
|
231
|
+
// `Bash` is honest about being a shell. `Bash(node:*)` is not.
|
|
232
|
+
if (t !== 'Bash' && t !== '*' && t !== 'All tools') scopedInNameOnly.push(t);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const ordered = Object.keys(POSTURES).filter((p) => postures.has(p));
|
|
237
|
+
return {
|
|
238
|
+
postures: ordered,
|
|
239
|
+
expensive: ordered.filter((p) => POSTURES[p].category),
|
|
240
|
+
unknownTools,
|
|
241
|
+
fullShellVia,
|
|
242
|
+
scopedInNameOnly,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One line for a human reviewing a grant, in their words. */
|
|
247
|
+
export function describePosture(r) {
|
|
248
|
+
const parts = [];
|
|
249
|
+
if (r.expensive.length) {
|
|
250
|
+
parts.push(`expensive: ${r.expensive.map((p) => `${p} (${CATEGORIES[POSTURES[p].category]})`).join('; ')}`);
|
|
251
|
+
} else if (r.postures.length) {
|
|
252
|
+
parts.push(`routine: ${r.postures.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
if (r.scopedInNameOnly.length) {
|
|
255
|
+
parts.push(`scoped in name only: ${r.scopedInNameOnly.join(', ')} — a full shell`);
|
|
256
|
+
}
|
|
257
|
+
if (r.unknownTools.length) {
|
|
258
|
+
parts.push(`NOT CLASSIFIED: ${r.unknownTools.join(', ')} — unjudged, not harmless`);
|
|
259
|
+
}
|
|
260
|
+
return parts.join(' · ') || 'no tools granted';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Every posture name, for a surface that wants a legend. */
|
|
264
|
+
export function knownPostures() {
|
|
265
|
+
return Object.keys(POSTURES);
|
|
266
|
+
}
|
package/package.json
CHANGED