@wyattjoh/demur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/extensions/demur/index.ts +63 -0
- package/package.json +70 -0
- package/src/adapters/claude-code.ts +71 -0
- package/src/analyze.ts +621 -0
- package/src/cli.ts +41 -0
- package/src/guard.internal.ts +164 -0
- package/src/guard.ts +84 -0
- package/src/judge.ts +233 -0
- package/src/key.ts +45 -0
- package/src/policy.ts +215 -0
- package/src/questions.ts +70 -0
- package/src/state.ts +287 -0
- package/src/types.ts +153 -0
package/src/policy.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { CommandAnalysis } from "./analyze.ts";
|
|
2
|
+
import type { Decision, Judgments } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decision thresholds applied to raw judgments.
|
|
6
|
+
*
|
|
7
|
+
* Policy lives here, apart from the judgments themselves, so thresholds can be
|
|
8
|
+
* retuned without changing the model questions or re-running inference.
|
|
9
|
+
*
|
|
10
|
+
* Each severity signal has a single deny threshold plus a shared uncertainty
|
|
11
|
+
* band. There are deliberately no separate "ask" thresholds, which could cross
|
|
12
|
+
* their corresponding deny thresholds and silently make branches unreachable.
|
|
13
|
+
* One threshold plus a band cannot express that invalid configuration.
|
|
14
|
+
*/
|
|
15
|
+
export type Thresholds = {
|
|
16
|
+
/**
|
|
17
|
+
* Below this probability of actually executing something, the command is
|
|
18
|
+
* treated as inert text and allowed outright. This is the gate that rescues
|
|
19
|
+
* `grep "rm -rf"`, heredocs quoting dangerous strings, and `sed` scripts that
|
|
20
|
+
* merely mention a scary path.
|
|
21
|
+
*/
|
|
22
|
+
executesDestruction: number;
|
|
23
|
+
/**
|
|
24
|
+
* Probability of unrecoverable loss at which a command is denied.
|
|
25
|
+
*/
|
|
26
|
+
denyUnrecoverable: number;
|
|
27
|
+
/**
|
|
28
|
+
* Probability of touching shared or production systems at which a command is
|
|
29
|
+
* denied.
|
|
30
|
+
*/
|
|
31
|
+
denySharedInfrastructure: number;
|
|
32
|
+
/**
|
|
33
|
+
* Expected blast radius at which a command is denied, on the 0–3 rubric.
|
|
34
|
+
*/
|
|
35
|
+
denyBlastRadius: number;
|
|
36
|
+
/**
|
|
37
|
+
* Blast-radius confidence below which a destructive command is escalated to a
|
|
38
|
+
* prompt rather than allowed. An uncertain model on a destructive command is
|
|
39
|
+
* exactly the case a human should see.
|
|
40
|
+
*/
|
|
41
|
+
minBlastRadiusConfidence: number;
|
|
42
|
+
/**
|
|
43
|
+
* Half-width of the uncertainty band around each probability threshold.
|
|
44
|
+
*
|
|
45
|
+
* Model judgments are not deterministic. Anything inside the band becomes
|
|
46
|
+
* `ask`, turning a borderline automatic decision into a question for the user.
|
|
47
|
+
*/
|
|
48
|
+
uncertaintyBand: number;
|
|
49
|
+
/**
|
|
50
|
+
* The same band for the 0–3 blast-radius scale rather than a probability.
|
|
51
|
+
*/
|
|
52
|
+
uncertaintyBandScore: number;
|
|
53
|
+
/**
|
|
54
|
+
* How destructive a command must look before an unresolvable target escalates
|
|
55
|
+
* it to a prompt.
|
|
56
|
+
*
|
|
57
|
+
* Without this floor the static gate also fires on `test -d "$p/.git"` and
|
|
58
|
+
* `command -v git` — read-only commands that merely contain a variable.
|
|
59
|
+
*/
|
|
60
|
+
staticGateMinExecution: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Shipped thresholds.
|
|
65
|
+
*
|
|
66
|
+
* These are proof-of-concept defaults, not safety guarantees. Re-validate them
|
|
67
|
+
* against your own threat model and command distribution.
|
|
68
|
+
*/
|
|
69
|
+
export const THRESHOLDS: Thresholds = {
|
|
70
|
+
executesDestruction: 0.3,
|
|
71
|
+
denyUnrecoverable: 0.45,
|
|
72
|
+
denySharedInfrastructure: 0.5,
|
|
73
|
+
denyBlastRadius: 2.0,
|
|
74
|
+
minBlastRadiusConfidence: 0.5,
|
|
75
|
+
uncertaintyBand: 0.04,
|
|
76
|
+
uncertaintyBandScore: 0.12,
|
|
77
|
+
staticGateMinExecution: 0.8,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A decision plus the sentence explaining it.
|
|
82
|
+
*/
|
|
83
|
+
export type PolicyOutcome = {
|
|
84
|
+
/**
|
|
85
|
+
* The action to take.
|
|
86
|
+
*/
|
|
87
|
+
decision: Decision;
|
|
88
|
+
/**
|
|
89
|
+
* Justification naming the signal that drove the decision.
|
|
90
|
+
*/
|
|
91
|
+
reason: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Turn raw judgments into an action.
|
|
96
|
+
*
|
|
97
|
+
* Order matters. The inert-text gate runs first, so a command that executes
|
|
98
|
+
* nothing is allowed however alarming its text looks. Then each severity signal
|
|
99
|
+
* is checked clear of its band (deny), and only afterwards is anything merely
|
|
100
|
+
* near a threshold escalated to a prompt.
|
|
101
|
+
*
|
|
102
|
+
* @param j - Judgments returned for the command
|
|
103
|
+
* @param t - Threshold set to apply; defaults to the shipped values
|
|
104
|
+
* @returns The decision and the reason behind it
|
|
105
|
+
*/
|
|
106
|
+
export function decide(j: Judgments, t: Thresholds = THRESHOLDS): PolicyOutcome {
|
|
107
|
+
const pct = (n: number) => `${Math.round(n * 100)}%`;
|
|
108
|
+
const band = t.uncertaintyBand;
|
|
109
|
+
const scoreBand = t.uncertaintyBandScore;
|
|
110
|
+
|
|
111
|
+
if (j.executesDestruction < t.executesDestruction - band) {
|
|
112
|
+
return {
|
|
113
|
+
decision: "allow",
|
|
114
|
+
reason: `No destructive operation is actually executed (${pct(j.executesDestruction)} likelihood); any dangerous-looking text is inert.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (j.targetsSharedInfrastructure >= t.denySharedInfrastructure + band) {
|
|
119
|
+
return {
|
|
120
|
+
decision: "deny",
|
|
121
|
+
reason: `Acts on shared or production infrastructure (${pct(j.targetsSharedInfrastructure)} likelihood). Run it yourself if that is intended.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (j.unrecoverable >= t.denyUnrecoverable + band) {
|
|
126
|
+
return {
|
|
127
|
+
decision: "deny",
|
|
128
|
+
reason: `Destroys work that cannot be recovered (${pct(j.unrecoverable)} likelihood). Commit, stash, or back up first.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (j.blastRadius >= t.denyBlastRadius + scoreBand) {
|
|
133
|
+
return {
|
|
134
|
+
decision: "deny",
|
|
135
|
+
reason: `Blast radius ${j.blastRadius.toFixed(1)}/3 — at this level the loss reaches irreplaceable or shared state.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Inside a band: the signal is too close to its threshold for the decision to
|
|
140
|
+
// be reproducible, so ask rather than flip a coin on the user's behalf.
|
|
141
|
+
if (j.targetsSharedInfrastructure >= t.denySharedInfrastructure - band) {
|
|
142
|
+
return {
|
|
143
|
+
decision: "ask",
|
|
144
|
+
reason: `Borderline on whether this touches shared or production infrastructure (${pct(j.targetsSharedInfrastructure)}).`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (j.unrecoverable >= t.denyUnrecoverable - band) {
|
|
149
|
+
return {
|
|
150
|
+
decision: "ask",
|
|
151
|
+
reason: `Borderline on whether the loss is recoverable (${pct(j.unrecoverable)}).`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (j.blastRadius >= t.denyBlastRadius - scoreBand) {
|
|
156
|
+
return {
|
|
157
|
+
decision: "ask",
|
|
158
|
+
reason: `Blast radius ${j.blastRadius.toFixed(1)}/3, right at the line where the loss stops being contained.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (j.blastRadiusConfidence < t.minBlastRadiusConfidence) {
|
|
163
|
+
return {
|
|
164
|
+
decision: "ask",
|
|
165
|
+
reason: `Destructive, and the blast radius is unclear (confidence ${j.blastRadiusConfidence.toFixed(2)}). Escalating rather than guessing.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (j.executesDestruction < t.executesDestruction + band) {
|
|
170
|
+
return {
|
|
171
|
+
decision: "ask",
|
|
172
|
+
reason: `Borderline on whether this executes anything at all (${pct(j.executesDestruction)}).`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
decision: "allow",
|
|
178
|
+
reason: `Destructive but contained: blast radius ${j.blastRadius.toFixed(1)}/3, ${pct(j.unrecoverable)} chance of unrecoverable loss.`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Refuse to allow a destructive command whose real target cannot be known.
|
|
184
|
+
*
|
|
185
|
+
* Some commands do not determine their own effect. A delete rooted at `$TMPDIR`
|
|
186
|
+
* removes whatever that variable happens to name. A glob in the program position
|
|
187
|
+
* depends on what it expands to. A command substitution runs whatever it prints.
|
|
188
|
+
*
|
|
189
|
+
* A judgment answers what a command *appears* to do. Where appearance and effect
|
|
190
|
+
* can diverge, that answer is not a basis for allowing it automatically, so the
|
|
191
|
+
* decision is floored at `ask`. Denials stand; this can only tighten a verdict.
|
|
192
|
+
*
|
|
193
|
+
* @param outcome - The decision reached from the judgments alone
|
|
194
|
+
* @param analysis - Static analysis of the same command
|
|
195
|
+
* @param judgments - The raw judgments, for the destructiveness floor
|
|
196
|
+
* @param t - Threshold set to apply; defaults to the shipped values
|
|
197
|
+
* @returns The outcome, escalated to `ask` when the target is unknowable
|
|
198
|
+
*/
|
|
199
|
+
export function applyStaticGate(
|
|
200
|
+
outcome: PolicyOutcome,
|
|
201
|
+
analysis: CommandAnalysis | undefined,
|
|
202
|
+
judgments: Judgments,
|
|
203
|
+
t: Thresholds = THRESHOLDS,
|
|
204
|
+
): PolicyOutcome {
|
|
205
|
+
if (analysis === undefined) return outcome;
|
|
206
|
+
if (outcome.decision !== "allow") return outcome;
|
|
207
|
+
if (!analysis.staticallyUnresolvable) return outcome;
|
|
208
|
+
if (judgments.executesDestruction < t.staticGateMinExecution) return outcome;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
decision: "ask",
|
|
212
|
+
reason:
|
|
213
|
+
"Destructive, and what it actually targets depends on the environment — a variable, a glob, or a command substitution — rather than on the command text. Confirm before running.",
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/questions.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { Decision } from "effect/unstable/ai";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The judgment set demur asks about every command.
|
|
6
|
+
*
|
|
7
|
+
* All four are sent in a single System One call. They are independent of one
|
|
8
|
+
* another and are evaluated in parallel, so asking all four costs one round
|
|
9
|
+
* trip. Code — not the model — decides which ones matter for a given verdict.
|
|
10
|
+
*
|
|
11
|
+
* Every question is phrased so that a high value means *more* dangerous,
|
|
12
|
+
* except `executesDestruction`, where a high value means the command really is
|
|
13
|
+
* an operation rather than inert text.
|
|
14
|
+
*
|
|
15
|
+
* A fifth question asking whether the command reaches outside the working
|
|
16
|
+
* directory was removed: "outside the working directory" turned out not to be a
|
|
17
|
+
* risk signal at all. It scored ~0.97 on any absolute path, including a rename
|
|
18
|
+
* within one directory, and no threshold sweep ever found a setting where it
|
|
19
|
+
* improved a decision.
|
|
20
|
+
*/
|
|
21
|
+
export const QUESTIONS = {
|
|
22
|
+
executesDestruction: Decision.probability({
|
|
23
|
+
instructions:
|
|
24
|
+
"Executing this command will actually carry out an operation that changes existing state, rather than only reading, searching, printing, or querying.",
|
|
25
|
+
criteria: {
|
|
26
|
+
true: "Running it performs a real operation on something that already exists: removing, overwriting, moving, resetting, or reconfiguring files, permissions, ownership, processes, containers, or remote resources. Judge what the command does when it runs, including anything it reaches through a shell, an interpreter, a wrapper such as `env` or `sudo`, command substitution, an alias, or a glob that expands to an executable.",
|
|
27
|
+
false:
|
|
28
|
+
"It only reads, searches, prints, lists, queries, or checks. Any destructive-looking text is inert: a search pattern, an argument to a read-only command, a string being printed or sent as data to a network service, quoted content inside a heredoc body that is payload rather than script, a comment, or documentation describing a command rather than running it.",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
unrecoverable: Decision.probability({
|
|
33
|
+
instructions:
|
|
34
|
+
"Whatever this command destroys could not be recovered afterwards from the state described.",
|
|
35
|
+
criteria: {
|
|
36
|
+
true: "The affected data has no second copy. Uncommitted edits, untracked files, unpushed commits with no upstream, or remote resources with no backup.",
|
|
37
|
+
false:
|
|
38
|
+
"Everything it touches can be restored: it is committed and pushed to git, reinstallable, regenerable by re-running a build, or a cache that rebuilds on demand.",
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
|
|
42
|
+
targetsSharedInfrastructure: Decision.probability({
|
|
43
|
+
instructions:
|
|
44
|
+
"This command acts on a shared, remote, or production system rather than on this machine's local checkout.",
|
|
45
|
+
criteria: {
|
|
46
|
+
true: "It operates on a production or shared database, a cloud account, a Kubernetes cluster, a remote host, a container registry, or a branch on a git remote.",
|
|
47
|
+
false:
|
|
48
|
+
"It operates only on local files, local containers, or the local git repository.",
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
blastRadius: Decision.rate({
|
|
53
|
+
instructions:
|
|
54
|
+
"How much would be lost if this command ran and the outcome turned out to be unwanted.",
|
|
55
|
+
criteria: [
|
|
56
|
+
"Nothing of value. The command is read-only, or it touches only caches, build output, temporary files, or files it created itself.",
|
|
57
|
+
"Recoverable work inside this project: committed files, dependencies that can be reinstalled, or generated code that can be regenerated.",
|
|
58
|
+
"Irreplaceable local work: uncommitted edits, untracked files, unpushed commits, or unrelated files elsewhere on this machine.",
|
|
59
|
+
"Shared or production state: a branch other people depend on, a production database, cloud infrastructure, or a live service.",
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Batched System One definition for all command judgments.
|
|
66
|
+
*/
|
|
67
|
+
export const COMMAND_JUDGMENTS = Decision.make({
|
|
68
|
+
input: Schema.Json,
|
|
69
|
+
decisions: QUESTIONS,
|
|
70
|
+
});
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
+
import { analyze, type CommandAnalysis } from "./analyze.ts";
|
|
3
|
+
import { Environment } from "./key.ts";
|
|
4
|
+
import type { CommandState, GitState, Host } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A JSON object accepted by the Effect decision model as System One state.
|
|
8
|
+
*/
|
|
9
|
+
type JsonObject = { [key: string]: JsonValue };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Any JSON-compatible value.
|
|
13
|
+
*/
|
|
14
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Milliseconds to wait for git before giving up and judging without it.
|
|
18
|
+
*
|
|
19
|
+
* Git state is an enrichment, not a requirement: a slow or broken repository
|
|
20
|
+
* must never delay the guard past its own budget.
|
|
21
|
+
*/
|
|
22
|
+
const GIT_TIMEOUT_MS = 400;
|
|
23
|
+
|
|
24
|
+
class GitCommandError extends Schema.TaggedError<GitCommandError>()("GitCommandError", {
|
|
25
|
+
cause: Schema.Defect(),
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Effect service for the bounded git queries used to enrich command state.
|
|
30
|
+
*/
|
|
31
|
+
export class GitCommand extends Context.Service<
|
|
32
|
+
GitCommand,
|
|
33
|
+
{
|
|
34
|
+
run(cwd: string, args: ReadonlyArray<string>): Effect.Effect<string | undefined>;
|
|
35
|
+
}
|
|
36
|
+
>()("demur/state/GitCommand") {
|
|
37
|
+
static readonly layer = Layer.succeed(
|
|
38
|
+
GitCommand,
|
|
39
|
+
GitCommand.of({
|
|
40
|
+
run: Effect.fn("GitCommand.run")(function* (
|
|
41
|
+
cwd: string,
|
|
42
|
+
args: ReadonlyArray<string>,
|
|
43
|
+
) {
|
|
44
|
+
return yield* Effect.tryPromise({
|
|
45
|
+
try: async (signal) => {
|
|
46
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
47
|
+
cwd,
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "ignore",
|
|
50
|
+
stdin: "ignore",
|
|
51
|
+
});
|
|
52
|
+
const abort = () => proc.kill();
|
|
53
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const [stdout, exitCode] = await Promise.all([
|
|
57
|
+
new Response(proc.stdout).text(),
|
|
58
|
+
proc.exited,
|
|
59
|
+
]);
|
|
60
|
+
return exitCode === 0 ? stdout.trim() : undefined;
|
|
61
|
+
} finally {
|
|
62
|
+
signal.removeEventListener("abort", abort);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
catch: (cause) => new GitCommandError({ cause }),
|
|
66
|
+
}).pipe(
|
|
67
|
+
Effect.timeoutOption(GIT_TIMEOUT_MS),
|
|
68
|
+
Effect.map(Option.getOrUndefined),
|
|
69
|
+
Effect.catch(() => Effect.succeed(undefined)),
|
|
70
|
+
);
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collect the git facts that change whether a command is really destructive.
|
|
78
|
+
*
|
|
79
|
+
* Uses a single porcelain v2 status with branch headers, so this is one process
|
|
80
|
+
* spawn rather than one per fact.
|
|
81
|
+
*
|
|
82
|
+
* @param cwd - Directory the command will run in
|
|
83
|
+
* @returns Git state, or `undefined` when `cwd` is not inside a work tree
|
|
84
|
+
*/
|
|
85
|
+
export const gatherGitStateEffect = Effect.fn("gatherGitStateEffect")(function* (
|
|
86
|
+
cwd: string,
|
|
87
|
+
): Effect.fn.Return<GitState | undefined, never, GitCommand> {
|
|
88
|
+
const git = yield* GitCommand;
|
|
89
|
+
const status = yield* git.run(cwd, [
|
|
90
|
+
"status",
|
|
91
|
+
"--porcelain=v2",
|
|
92
|
+
"--branch",
|
|
93
|
+
"--untracked-files=normal",
|
|
94
|
+
]);
|
|
95
|
+
if (status === undefined) return undefined;
|
|
96
|
+
|
|
97
|
+
const root = yield* git.run(cwd, ["rev-parse", "--show-toplevel"]);
|
|
98
|
+
if (root === undefined) return undefined;
|
|
99
|
+
|
|
100
|
+
let branch: string | undefined;
|
|
101
|
+
let hasUpstream = false;
|
|
102
|
+
let unpushedCommitCount = 0;
|
|
103
|
+
let uncommittedFileCount = 0;
|
|
104
|
+
let untrackedFileCount = 0;
|
|
105
|
+
|
|
106
|
+
for (const line of status.split("\n")) {
|
|
107
|
+
if (line.startsWith("# branch.head ")) {
|
|
108
|
+
const head = line.slice("# branch.head ".length);
|
|
109
|
+
branch = head === "(detached)" ? undefined : head;
|
|
110
|
+
} else if (line.startsWith("# branch.upstream ")) {
|
|
111
|
+
hasUpstream = true;
|
|
112
|
+
} else if (line.startsWith("# branch.ab ")) {
|
|
113
|
+
// Format: "# branch.ab +<ahead> -<behind>"
|
|
114
|
+
const ahead = line.slice("# branch.ab ".length).split(" ")[0];
|
|
115
|
+
unpushedCommitCount = Math.max(0, Number(ahead ?? 0) || 0);
|
|
116
|
+
} else if (line.startsWith("? ")) {
|
|
117
|
+
untrackedFileCount += 1;
|
|
118
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u ")) {
|
|
119
|
+
uncommittedFileCount += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
root,
|
|
125
|
+
branch,
|
|
126
|
+
uncommittedFileCount,
|
|
127
|
+
untrackedFileCount,
|
|
128
|
+
unpushedCommitCount,
|
|
129
|
+
hasUpstream,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Collect the git facts through the live Effect service.
|
|
135
|
+
*
|
|
136
|
+
* @param cwd - Directory the command will run in
|
|
137
|
+
* @returns Git state, or `undefined` when `cwd` is not inside a work tree
|
|
138
|
+
*/
|
|
139
|
+
export function gatherGitState(cwd: string): Promise<GitState | undefined> {
|
|
140
|
+
return Effect.runPromise(
|
|
141
|
+
gatherGitStateEffect(cwd).pipe(Effect.provide(GitCommand.layer)),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the full state handed to the model for one command.
|
|
147
|
+
*
|
|
148
|
+
* @param command - The shell command about to run
|
|
149
|
+
* @param cwd - Absolute working directory for the command
|
|
150
|
+
* @param agent - Which coding agent is asking
|
|
151
|
+
* @returns State ready to send as a System One request
|
|
152
|
+
*/
|
|
153
|
+
export const gatherStateEffect = Effect.fn("gatherStateEffect")(function* (
|
|
154
|
+
command: string,
|
|
155
|
+
cwd: string,
|
|
156
|
+
agent: Host,
|
|
157
|
+
): Effect.fn.Return<CommandState, never, Environment | GitCommand> {
|
|
158
|
+
const environment = yield* Environment;
|
|
159
|
+
const [git, home, tmpDir] = yield* Effect.all([
|
|
160
|
+
gatherGitStateEffect(cwd),
|
|
161
|
+
environment.get("HOME"),
|
|
162
|
+
environment.get("TMPDIR"),
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
command,
|
|
167
|
+
cwd,
|
|
168
|
+
agent,
|
|
169
|
+
git,
|
|
170
|
+
nonce: undefined,
|
|
171
|
+
// Computed for the static gate in policy.ts, NOT sent to the model.
|
|
172
|
+
// renderState below explains why it is withheld from the request.
|
|
173
|
+
analysis: analyze(command, cwd, home ?? "/root", tmpDir?.replace(/\/$/, "")),
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build state through the live Effect services.
|
|
179
|
+
*
|
|
180
|
+
* @param command - The shell command about to run
|
|
181
|
+
* @param cwd - Absolute working directory for the command
|
|
182
|
+
* @param agent - Which coding agent is asking
|
|
183
|
+
* @returns State ready to send as a System One request
|
|
184
|
+
*/
|
|
185
|
+
export function gatherState(
|
|
186
|
+
command: string,
|
|
187
|
+
cwd: string,
|
|
188
|
+
agent: Host,
|
|
189
|
+
): Promise<CommandState> {
|
|
190
|
+
return Effect.runPromise(
|
|
191
|
+
gatherStateEffect(command, cwd, agent).pipe(
|
|
192
|
+
Effect.provide(Layer.merge(Environment.layer, GitCommand.layer)),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render state as the JSON object sent to the model.
|
|
199
|
+
*
|
|
200
|
+
* Field names are part of the prompt, so they are spelled out rather than
|
|
201
|
+
* abbreviated, and git facts are flattened with a short explanation of what an
|
|
202
|
+
* absent repository means.
|
|
203
|
+
*
|
|
204
|
+
* @param state - Collected command state
|
|
205
|
+
* @returns A plain JSON object suitable for the `state` field
|
|
206
|
+
*/
|
|
207
|
+
export function renderState(state: CommandState): JsonObject {
|
|
208
|
+
const out: JsonObject = {
|
|
209
|
+
command: state.command,
|
|
210
|
+
working_directory: state.cwd,
|
|
211
|
+
requesting_agent: state.agent,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (state.nonce !== undefined) out.request_nonce = state.nonce;
|
|
215
|
+
|
|
216
|
+
out.version_control =
|
|
217
|
+
state.git === undefined
|
|
218
|
+
? "Not inside a git repository."
|
|
219
|
+
: {
|
|
220
|
+
repository_root: state.git.root,
|
|
221
|
+
current_branch: state.git.branch ?? "(detached HEAD)",
|
|
222
|
+
uncommitted_modified_files: state.git.uncommittedFileCount,
|
|
223
|
+
untracked_files: state.git.untrackedFileCount,
|
|
224
|
+
unpushed_commits: state.git.unpushedCommitCount,
|
|
225
|
+
branch_has_remote_upstream: state.git.hasUpstream,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// The static analysis in `analyze.ts` is deliberately NOT sent. It exists for
|
|
229
|
+
// the deterministic policy gate; the model judges the original command rather
|
|
230
|
+
// than a lossy summary of it.
|
|
231
|
+
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Render the static analysis as state the model can read directly.
|
|
237
|
+
*
|
|
238
|
+
* Retained for deterministic policy and future hybrid guards. It is not part of
|
|
239
|
+
* the model request; see {@link renderState}.
|
|
240
|
+
*
|
|
241
|
+
* The wording of each key is part of the prompt. `program` in particular is
|
|
242
|
+
* spelled out as the real executable, because its whole purpose is to stop the
|
|
243
|
+
* model from reading `rm` out of the middle of `transform`.
|
|
244
|
+
*
|
|
245
|
+
* @param analysis - Analysis produced by {@link analyze}
|
|
246
|
+
* @returns A JSON object describing what code determined
|
|
247
|
+
*/
|
|
248
|
+
export function renderAnalysis(analysis: CommandAnalysis): JsonObject {
|
|
249
|
+
const out: JsonObject = {
|
|
250
|
+
note: "A breakdown of what this command will do when it runs. `program` is the executable that will actually be invoked, after removing quotes and stripping wrappers such as env or sudo. `paths` are resolved for ~, $TMPDIR, and .. before being located. Every command listed here executes.",
|
|
251
|
+
commands: analysis.segments.map((segment) => {
|
|
252
|
+
const entry: JsonObject = {
|
|
253
|
+
program: segment.argv0 ?? "(could not determine)",
|
|
254
|
+
arguments: segment.args,
|
|
255
|
+
};
|
|
256
|
+
if (segment.wrappers.length > 0) entry.invoked_through = segment.wrappers;
|
|
257
|
+
if (segment.paths.length > 0) {
|
|
258
|
+
entry.paths = segment.paths.map((p) => ({
|
|
259
|
+
as_written: p.value,
|
|
260
|
+
resolves_to: p.resolved ?? "(contains an unresolved variable)",
|
|
261
|
+
location: p.class,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
return entry;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (analysis.substitutions.length > 0) {
|
|
269
|
+
out.commands_inside_substitutions = analysis.substitutions;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (analysis.heredocs.length > 0) {
|
|
273
|
+
out.heredoc_bodies = analysis.heredocs.map((h) => ({
|
|
274
|
+
delimiter: h.tag,
|
|
275
|
+
fed_to: h.consumer ?? "(unknown)",
|
|
276
|
+
shell_expansion_suppressed: h.quoted,
|
|
277
|
+
body: h.body.length > 500 ? `${h.body.slice(0, 500)}…` : h.body,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!analysis.parsedCleanly) {
|
|
282
|
+
out.parse_warning =
|
|
283
|
+
"Part of this command could not be parsed cleanly; treat the breakdown above as incomplete.";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return out;
|
|
287
|
+
}
|