@yagni-app/code 1.0.7 → 1.0.8
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/README.md +8 -1
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +6 -0
- package/dist/extension/index.js +56 -38
- package/dist/extension/permission/gate.d.ts +5 -1
- package/dist/extension/permission/gate.js +138 -44
- package/dist/extension/permissionRules/loadConfig.d.ts +23 -12
- package/dist/extension/permissionRules/loadConfig.js +29 -14
- package/dist/extension/permissionRules/pathRules.d.ts +9 -7
- package/dist/extension/permissionRules/pathRules.js +10 -8
- package/dist/extension/sandbox/config.d.ts +15 -14
- package/dist/extension/sandbox/config.js +62 -40
- package/dist/extension/sandbox/manager.d.ts +10 -0
- package/dist/extension/sandbox/manager.js +28 -1
- package/dist/extension/sandbox/session.js +150 -96
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/config.d.ts +5 -1
- package/dist/extension/telemetry/register.d.ts +7 -0
- package/dist/extension/telemetry/register.js +15 -0
- package/dist/upgrade.js +10 -1
- package/package.json +2 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared settings-file plumbing for the three YAGNI Code settings tiers:
|
|
3
|
+
* user ~/.yagni-code/config.json (always trusted)
|
|
4
|
+
* project .yagni-code/config.json under the cwd (shared, committed)
|
|
5
|
+
* local .yagni-code/config.local.json under cwd (personal, gitignored)
|
|
6
|
+
*
|
|
7
|
+
* Owns the atomic config mutation (promoted from sandbox/session.ts, the
|
|
8
|
+
* single copy now) and the global-gitignore helper (Claude Code
|
|
9
|
+
* addFileGlobRuleToGitignore parity): on the first write to the local file,
|
|
10
|
+
* the LOCAL_GITIGNORE_ENTRY glob lands in the GLOBAL git ignore file so
|
|
11
|
+
* personal settings are never committed. Fail-soft by design — a gitignore
|
|
12
|
+
* failure never fails the settings write.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { logEvent } from "./errorSink.js";
|
|
20
|
+
const LOCAL_GITIGNORE_ENTRY = "**/.yagni-code/config.local.json";
|
|
21
|
+
function isPlainRecord(v) {
|
|
22
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Safe config-file mutation shared by every settings writer:
|
|
26
|
+
* - a PARSE FAILURE aborts (never treats a corrupt/half-written file as
|
|
27
|
+
* empty — that would wipe activeProfile/permissions);
|
|
28
|
+
* - the write is ATOMIC (tmp file + rename) so a crash never truncates;
|
|
29
|
+
* - symlinked targets are rejected (a hostile repo's config symlink must
|
|
30
|
+
* not redirect writes to arbitrary paths);
|
|
31
|
+
* - the mutator runs only on a parsed object; a thrown mutator aborts.
|
|
32
|
+
* Throws on any failure — callers decide fail-soft messaging.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Reject when the target's EXISTING parent directory is a symlink — a
|
|
36
|
+
* hostile repo can commit a symlinked `.yagni-code` DIRECTORY, and every
|
|
37
|
+
* local-settings write would then land outside the repo via the symlinked
|
|
38
|
+
* parent (the final-component lstat guard cannot see this). The parent is
|
|
39
|
+
* the repo-committable component (`.yagni-code` for project/local targets,
|
|
40
|
+
* the state home for user targets); ancestors above it are the user's own
|
|
41
|
+
* filesystem, not repo-controlled. Absent parent is fine — mkdirSync
|
|
42
|
+
* creates it fresh, and mkdir does not follow a symlinked path segment it
|
|
43
|
+
* creates itself. Fail closed.
|
|
44
|
+
*/
|
|
45
|
+
function assertRealParentDir(target) {
|
|
46
|
+
const dir = dirname(target);
|
|
47
|
+
try {
|
|
48
|
+
const st = lstatSync(dir);
|
|
49
|
+
if (!st.isDirectory() || st.isSymbolicLink()) {
|
|
50
|
+
throw new Error(`${dir} is not a real directory (symlinked parent?) — refusing to write through it`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.code === "ENOENT")
|
|
55
|
+
return; // absent — created fresh below
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function mutateConfigJson(target, mutate) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(readFileSync(target, "utf-8"));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err.code === "ENOENT") {
|
|
66
|
+
parsed = {}; // genuinely fresh file — fine
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
throw new Error(`config at ${target} is not valid JSON — refusing to rewrite it`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!isPlainRecord(parsed))
|
|
73
|
+
throw new Error(`config at ${target} is not an object`);
|
|
74
|
+
const config = { ...parsed };
|
|
75
|
+
mutate(config);
|
|
76
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
77
|
+
// Reject symlinked targets: lstat must show a regular file (or absent).
|
|
78
|
+
try {
|
|
79
|
+
const st = lstatSync(target);
|
|
80
|
+
if (!st.isFile())
|
|
81
|
+
throw new Error(`${target} is not a regular file (symlink?) — refusing`);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code !== "ENOENT")
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
// And a symlinked PARENT directory: a committed symlinked .yagni-code
|
|
88
|
+
// dir would redirect the write outside the project — the final-component
|
|
89
|
+
// guard above cannot see it.
|
|
90
|
+
assertRealParentDir(target);
|
|
91
|
+
const tmp = `${target}.yagni-tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
92
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
93
|
+
// chmod: writeFile honors mode only on create — guarantee 0600 even when a
|
|
94
|
+
// same-pid retry reuses an existing tmp file.
|
|
95
|
+
chmodSync(tmp, 0o600);
|
|
96
|
+
renameSync(tmp, target);
|
|
97
|
+
}
|
|
98
|
+
/** The project-local settings path for a session cwd. */
|
|
99
|
+
export function localConfigPath(cwd) {
|
|
100
|
+
return join(cwd, ".yagni-code", "config.local.json");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mutate the project-local settings file. Every write fire-and-forgets
|
|
104
|
+
* ensureLocalGitignored (Claude parity: the local tier is added to the
|
|
105
|
+
* global git ignore on write, not on session start). The helper is
|
|
106
|
+
* fire-and-forget BY DESIGN — the write's success notice is not gated on
|
|
107
|
+
* two git spawns; when the gitignore step is skipped or fails, the sink
|
|
108
|
+
* warn (gitignore_skipped / gitignore_update_failed) is the designed
|
|
109
|
+
* signal, surfaced via the diagnostics trail.
|
|
110
|
+
*
|
|
111
|
+
* `sinkSource` attributes those sink events to the caller's surface
|
|
112
|
+
* ("permission-rules" for rule saves, "sandbox" for panel/toggle writes) —
|
|
113
|
+
* the diagnostics trail then says which surface's write failed to get
|
|
114
|
+
* ignore-protected, not a blanket permission-rules line for every write.
|
|
115
|
+
*/
|
|
116
|
+
export function mutateLocalConfig(cwd, mutate, sinkSource = "permission-rules") {
|
|
117
|
+
mutateConfigJson(localConfigPath(cwd), mutate);
|
|
118
|
+
void ensureLocalGitignored(cwd, process.env, sinkSource);
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Global-gitignore helper (Claude addFileGlobRuleToGitignore parity)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/** Run git <args> in cwd; resolves exit code (never throws). env threads
|
|
124
|
+
* through so the caller's PATH governs git resolution. */
|
|
125
|
+
function git(cwd, args, env) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
try {
|
|
128
|
+
const child = spawn("git", args, { cwd, env, stdio: "ignore" });
|
|
129
|
+
child.on("error", () => resolve(GIT_NOT_INSTALLED));
|
|
130
|
+
child.on("exit", (code) => resolve(code ?? GIT_NOT_INSTALLED));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
resolve(GIT_NOT_INSTALLED);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const GIT_NOT_INSTALLED = -1;
|
|
138
|
+
/**
|
|
139
|
+
* The global git ignore path git actually reads. Git consults
|
|
140
|
+
* $XDG_CONFIG_HOME/git/ignore (defaulting ~/.config/git/ignore) — honoring
|
|
141
|
+
* XDG is a deliberate divergence from Claude (which hardcodes ~/.config):
|
|
142
|
+
* writing a file git will not read when XDG is set would be a silent no-op.
|
|
143
|
+
*/
|
|
144
|
+
export function globalGitignorePath(env = process.env) {
|
|
145
|
+
const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim() !== "" ? env.XDG_CONFIG_HOME : null;
|
|
146
|
+
return join(xdg ?? join(homedir(), ".config"), "git", "ignore");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Ensure the LOCAL_GITIGNORE_ENTRY glob (covering config.local.json at any
|
|
150
|
+
* depth) is ignored by the user's GLOBAL git config so local settings are
|
|
151
|
+
* never committed. Skip when:
|
|
152
|
+
* - cwd is not inside a git repo (nothing to protect);
|
|
153
|
+
* - `git check-ignore` already matches (local or global patterns cover it);
|
|
154
|
+
* - the global ignore file already carries the literal entry.
|
|
155
|
+
* Any failure logs one warn sink event and returns — the settings write it
|
|
156
|
+
* accompanies has already succeeded and must not be walked back.
|
|
157
|
+
*/
|
|
158
|
+
export async function ensureLocalGitignored(cwd, env = process.env, sinkSource = "permission-rules") {
|
|
159
|
+
try {
|
|
160
|
+
const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"], env);
|
|
161
|
+
// -1 = git missing or failed to spawn — protection silently skipped; a
|
|
162
|
+
// warn makes that visible (the file exists un-ignored). 128 = cleanly
|
|
163
|
+
// not a repo — the documented skip, nothing to surface.
|
|
164
|
+
if (inside === GIT_NOT_INSTALLED) {
|
|
165
|
+
logEvent({
|
|
166
|
+
source: sinkSource,
|
|
167
|
+
level: "warn",
|
|
168
|
+
event: "gitignore_skipped",
|
|
169
|
+
fields: { reason: "git-unavailable" },
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Not inside a repo (128) or a failed rev-parse: skip, nothing to do.
|
|
174
|
+
if (inside !== 0)
|
|
175
|
+
return;
|
|
176
|
+
// 0 = already ignored by some pattern (project .gitignore or global).
|
|
177
|
+
if ((await git(cwd, ["check-ignore", ".yagni-code/config.local.json"], env)) === 0)
|
|
178
|
+
return;
|
|
179
|
+
const path = globalGitignorePath(env);
|
|
180
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
181
|
+
if (existsSync(path)) {
|
|
182
|
+
const content = readFileSync(path, "utf-8");
|
|
183
|
+
if (content.includes(LOCAL_GITIGNORE_ENTRY))
|
|
184
|
+
return;
|
|
185
|
+
appendFileSync(path, `${content.endsWith("\n") ? "" : "\n"}${LOCAL_GITIGNORE_ENTRY}\n`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
writeFileSync(path, `${LOCAL_GITIGNORE_ENTRY}\n`, { mode: 0o644 });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
logEvent({
|
|
193
|
+
source: sinkSource,
|
|
194
|
+
level: "warn",
|
|
195
|
+
event: "gitignore_update_failed",
|
|
196
|
+
fields: {
|
|
197
|
+
path: globalGitignorePath(env),
|
|
198
|
+
// The thrown message names the failing operation and the local
|
|
199
|
+
// filesystem path (mkdir/append of the ignore file) — no secret
|
|
200
|
+
// surface, and it makes EACCES-vs-ENOSPC diagnosable.
|
|
201
|
+
error: err instanceof Error ? err.message : String(err),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=settingsFiles.js.map
|
|
@@ -37,7 +37,11 @@ export interface SignalConfig {
|
|
|
37
37
|
export interface TelemetryIdentity {
|
|
38
38
|
/** Bound workspace id (YAGNI_WORKSPACE_ID) → `organization.id`. */
|
|
39
39
|
organizationId?: string;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The signed-in user's email → `user.email`. From `YAGNI_USER_EMAIL` when
|
|
42
|
+
* the launcher forwards it; otherwise learned from the /context boot fetch
|
|
43
|
+
* (`TelemetryHandle.setUserEmail`). Gated by `includeAccountId`.
|
|
44
|
+
*/
|
|
41
45
|
userEmail?: string;
|
|
42
46
|
/** Shipping CLI version (YAGNI_CODE_VERSION) → `app.version`. */
|
|
43
47
|
appVersion?: string;
|
|
@@ -27,6 +27,13 @@ export interface TelemetryHandle {
|
|
|
27
27
|
toolDecision(input: ToolDecisionInput): void;
|
|
28
28
|
/** The mode holder calls this on every /mode (or shift-tab) change. */
|
|
29
29
|
permissionModeChanged(fromMode: string, toMode: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* The entry calls this with the account email the /context boot fetch
|
|
32
|
+
* returned. A launcher-forwarded `YAGNI_USER_EMAIL` wins; otherwise every
|
|
33
|
+
* span, metric, and event from here on carries `user.email` (still subject
|
|
34
|
+
* to the `includeAccountId` gate).
|
|
35
|
+
*/
|
|
36
|
+
setUserEmail(email: string | undefined): void;
|
|
30
37
|
/** Test/introspection seam: the live tracker once the SDK is up. */
|
|
31
38
|
readonly tracker: SessionTelemetry | null;
|
|
32
39
|
}
|
|
@@ -27,6 +27,7 @@ const NOOP_HANDLE = (config) => ({
|
|
|
27
27
|
config,
|
|
28
28
|
toolDecision: () => { },
|
|
29
29
|
permissionModeChanged: () => { },
|
|
30
|
+
setUserEmail: () => { },
|
|
30
31
|
tracker: null,
|
|
31
32
|
});
|
|
32
33
|
export function registerTelemetry(pi, deps = {}) {
|
|
@@ -187,6 +188,20 @@ export function registerTelemetry(pi, deps = {}) {
|
|
|
187
188
|
},
|
|
188
189
|
toolDecision: guard("tool_decision", (input) => tracker?.toolDecision(input)),
|
|
189
190
|
permissionModeChanged: guard("permission_mode_changed", (from, to) => tracker?.permissionModeChanged(from, to)),
|
|
191
|
+
// Precedence, lowest to highest: this call (the /context boot fetch) <
|
|
192
|
+
// the launcher's YAGNI_USER_EMAIL, which resolveTelemetryConfig already
|
|
193
|
+
// placed on `identity`. So an identity that is set is never overwritten,
|
|
194
|
+
// whichever writer set it; a new writer must slot into this order
|
|
195
|
+
// explicitly rather than rely on call timing. The tracker reads
|
|
196
|
+
// `config.identity` on every attribute set, so this applies whether the
|
|
197
|
+
// SDK is already up or still lazy-loading.
|
|
198
|
+
setUserEmail: (email) => {
|
|
199
|
+
if (config.identity.userEmail)
|
|
200
|
+
return;
|
|
201
|
+
const trimmed = typeof email === "string" ? email.trim() : "";
|
|
202
|
+
if (trimmed)
|
|
203
|
+
config.identity.userEmail = trimmed;
|
|
204
|
+
},
|
|
190
205
|
};
|
|
191
206
|
}
|
|
192
207
|
//# sourceMappingURL=register.js.map
|
package/dist/upgrade.js
CHANGED
|
@@ -249,13 +249,22 @@ export function parseUpgradeArgs(args) {
|
|
|
249
249
|
}
|
|
250
250
|
return { ok: true, target, method };
|
|
251
251
|
}
|
|
252
|
+
// npm's informational channels (deprecation warnings, the funding notice,
|
|
253
|
+
// and npm ≥11.13's `allowScripts` notice) are noise under an upgrade the user
|
|
254
|
+
// explicitly asked for; errors and the added-packages summary stay visible.
|
|
255
|
+
// Note the allowScripts notice is advisory only: with the default (non-strict)
|
|
256
|
+
// policy npm still runs the listed scripts, so quieting it changes nothing
|
|
257
|
+
// functionally. Audit findings go to stdout, unaffected by --loglevel.
|
|
258
|
+
const NPM_QUIET_FLAGS = ["--loglevel=error", "--no-fund", "--no-audit"];
|
|
252
259
|
function installArgv(method, target) {
|
|
253
260
|
// brew upgrades to whatever the tap formula publishes; it cannot pin a
|
|
254
261
|
// version, so `target` only tells us an upgrade is worthwhile.
|
|
255
262
|
if (method === "brew")
|
|
256
263
|
return ["upgrade", BREW_FORMULA];
|
|
257
264
|
const spec = `${PACKAGE_NAME}@${target}`;
|
|
258
|
-
return method === "npm"
|
|
265
|
+
return method === "npm"
|
|
266
|
+
? ["install", "-g", ...NPM_QUIET_FLAGS, spec]
|
|
267
|
+
: ["add", "-g", spec];
|
|
259
268
|
}
|
|
260
269
|
function failureHint(method) {
|
|
261
270
|
if (method === "brew") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "9b213ea65690a4544d0cdf093b6903aab8a1456b"
|
|
62
62
|
}
|