@gajae-code/coding-agent 0.3.2 → 0.4.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/CHANGELOG.md +39 -0
- package/dist/types/config/model-registry.d.ts +17 -10
- package/dist/types/config/models-config-schema.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +5 -0
- package/dist/types/edit/diff.d.ts +16 -0
- package/dist/types/edit/modes/replace.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/activation.d.ts +14 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +31 -0
- package/dist/types/extensibility/gjc-plugins/loader.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/schema.d.ts +3 -0
- package/dist/types/extensibility/gjc-plugins/state.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/tools.d.ts +8 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +4 -0
- package/dist/types/extensibility/skills.d.ts +9 -1
- package/dist/types/gjc-runtime/state-runtime.d.ts +22 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -2
- package/dist/types/harness-control-plane/storage.d.ts +7 -0
- package/dist/types/lsp/client.d.ts +1 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +2 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-client.d.ts +19 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +179 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +57 -0
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +16 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +47 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +11 -1
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +3 -1
- package/dist/types/modes/shared/agent-wire/responses.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +27 -0
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +68 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +161 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +61 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +114 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +39 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/sdk.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +10 -0
- package/dist/types/session/blob-store.d.ts +17 -0
- package/dist/types/session/messages.d.ts +3 -0
- package/dist/types/session/session-storage.d.ts +6 -0
- package/dist/types/skill-state/active-state.d.ts +13 -0
- package/dist/types/thinking.d.ts +3 -2
- package/dist/types/tools/index.d.ts +3 -0
- package/package.json +9 -7
- package/src/cli.ts +14 -0
- package/src/commands/harness.ts +192 -7
- package/src/commands/ultragoal.ts +1 -21
- package/src/config/model-equivalence.ts +1 -1
- package/src/config/model-registry.ts +32 -5
- package/src/config/models-config-schema.ts +7 -2
- package/src/config/settings-schema.ts +4 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +19 -23
- package/src/defaults/gjc/skills/ralplan/SKILL.md +7 -7
- package/src/discovery/claude-plugins.ts +25 -5
- package/src/edit/diff.ts +64 -1
- package/src/edit/modes/replace.ts +60 -2
- package/src/extensibility/gjc-plugins/activation.ts +87 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +114 -0
- package/src/extensibility/gjc-plugins/loader.ts +131 -0
- package/src/extensibility/gjc-plugins/paths.ts +66 -0
- package/src/extensibility/gjc-plugins/schema.ts +79 -0
- package/src/extensibility/gjc-plugins/state.ts +29 -0
- package/src/extensibility/gjc-plugins/tools.ts +47 -0
- package/src/extensibility/gjc-plugins/types.ts +97 -0
- package/src/extensibility/gjc-plugins/validation.ts +76 -0
- package/src/extensibility/skills.ts +39 -7
- package/src/gjc-runtime/state-runtime.ts +93 -2
- package/src/gjc-runtime/state-writer.ts +17 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +76 -121
- package/src/gjc-runtime/workflow-manifest.generated.json +5 -0
- package/src/gjc-runtime/workflow-manifest.ts +2 -2
- package/src/harness-control-plane/storage.ts +144 -2
- package/src/hashline/hash.ts +23 -0
- package/src/hooks/skill-state.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/lsp/client.ts +7 -0
- package/src/modes/acp/acp-agent.ts +25 -2
- package/src/modes/bridge/bridge-mode.ts +124 -2
- package/src/modes/controllers/input-controller.ts +14 -2
- package/src/modes/prompt-action-autocomplete.ts +49 -10
- package/src/modes/rpc/rpc-client.ts +79 -3
- package/src/modes/rpc/rpc-mode.ts +67 -0
- package/src/modes/rpc/rpc-types.ts +224 -2
- package/src/modes/shared/agent-wire/approval-gate.ts +151 -0
- package/src/modes/shared/agent-wire/command-dispatch.ts +97 -4
- package/src/modes/shared/agent-wire/command-validation.ts +25 -1
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +222 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +13 -0
- package/src/modes/shared/agent-wire/handshake.ts +43 -3
- package/src/modes/shared/agent-wire/protocol.ts +7 -0
- package/src/modes/shared/agent-wire/responses.ts +2 -2
- package/src/modes/shared/agent-wire/scopes.ts +2 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +341 -0
- package/src/modes/shared/agent-wire/unattended-audit.ts +175 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +406 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +180 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +324 -0
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +331 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/system/system-prompt.md +9 -0
- package/src/runtime-mcp/client.ts +7 -4
- package/src/runtime-mcp/manager.ts +45 -13
- package/src/runtime-mcp/transports/http.ts +40 -14
- package/src/runtime-mcp/transports/stdio.ts +11 -10
- package/src/sdk.ts +47 -0
- package/src/session/agent-session.ts +211 -2
- package/src/session/blob-store.ts +84 -0
- package/src/session/messages.ts +3 -0
- package/src/session/session-manager.ts +390 -33
- package/src/session/session-storage.ts +26 -0
- package/src/setup/provider-onboarding.ts +2 -2
- package/src/skill-state/active-state.ts +89 -1
- package/src/task/discovery.ts +7 -1
- package/src/task/executor.ts +16 -2
- package/src/thinking.ts +8 -2
- package/src/tools/ask.ts +39 -9
- package/src/tools/index.ts +3 -0
- package/src/tools/skill.ts +15 -3
- package/src/utils/edit-mode.ts +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unattended action taxonomy + classifier (#319).
|
|
3
|
+
*
|
|
4
|
+
* Maps coarse command scopes and concrete bash commands onto the v1 action
|
|
5
|
+
* taxonomy so the controller can authorize (default-deny) BEFORE any side effect.
|
|
6
|
+
*
|
|
7
|
+
* The classifier is deliberately FAIL-CLOSED for shell evasion:
|
|
8
|
+
* - nested execution via `$(...)`, backticks, and `<(...)` / `>(...)` is
|
|
9
|
+
* extracted and classified recursively, so a destructive command hidden in a
|
|
10
|
+
* substitution cannot masquerade as the harmless outer command;
|
|
11
|
+
* - statements are split on newlines and `; && || | &`, so a second
|
|
12
|
+
* (destructive) command on another line is classified too;
|
|
13
|
+
* - leading environment assignments and wrappers (`sudo`, `env`, `command`,
|
|
14
|
+
* `xargs`, …) are stripped/followed to the effective command;
|
|
15
|
+
* - anything that is not provably read-only escalates to at least
|
|
16
|
+
* `bash.mutating`, and clearly destructive forms escalate further, so an
|
|
17
|
+
* undeclared destructive action is denied rather than silently allowed.
|
|
18
|
+
*/
|
|
19
|
+
import type { RpcUnattendedActionClass } from "../../rpc/rpc-types";
|
|
20
|
+
import type { BridgeCommandScope } from "./scopes";
|
|
21
|
+
|
|
22
|
+
/** Coarse command scope -> `command.<scope>` action class. */
|
|
23
|
+
export function actionClassForScope(scope: BridgeCommandScope): RpcUnattendedActionClass {
|
|
24
|
+
switch (scope) {
|
|
25
|
+
case "prompt":
|
|
26
|
+
return "command.prompt";
|
|
27
|
+
case "control":
|
|
28
|
+
return "command.control";
|
|
29
|
+
case "bash":
|
|
30
|
+
return "command.bash";
|
|
31
|
+
case "export":
|
|
32
|
+
return "command.export";
|
|
33
|
+
case "session":
|
|
34
|
+
return "command.session";
|
|
35
|
+
case "model":
|
|
36
|
+
return "command.model";
|
|
37
|
+
case "message:read":
|
|
38
|
+
return "command.message_read";
|
|
39
|
+
case "host_tools":
|
|
40
|
+
return "command.host_tools";
|
|
41
|
+
case "host_uri":
|
|
42
|
+
return "command.host_uri";
|
|
43
|
+
case "admin":
|
|
44
|
+
return "command.admin";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const READONLY_COMMANDS = new Set([
|
|
49
|
+
"ls",
|
|
50
|
+
"cat",
|
|
51
|
+
"pwd",
|
|
52
|
+
"echo",
|
|
53
|
+
"printf",
|
|
54
|
+
"grep",
|
|
55
|
+
"rg",
|
|
56
|
+
"head",
|
|
57
|
+
"tail",
|
|
58
|
+
"wc",
|
|
59
|
+
"stat",
|
|
60
|
+
"file",
|
|
61
|
+
"which",
|
|
62
|
+
"type",
|
|
63
|
+
"date",
|
|
64
|
+
"whoami",
|
|
65
|
+
"true",
|
|
66
|
+
"false",
|
|
67
|
+
"test",
|
|
68
|
+
"diff",
|
|
69
|
+
"sort",
|
|
70
|
+
"uniq",
|
|
71
|
+
"cut",
|
|
72
|
+
"basename",
|
|
73
|
+
"dirname",
|
|
74
|
+
"realpath",
|
|
75
|
+
"head",
|
|
76
|
+
"tail",
|
|
77
|
+
"cmp",
|
|
78
|
+
"column",
|
|
79
|
+
"tr",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const MUTATING_COMMANDS = new Set([
|
|
83
|
+
"mv",
|
|
84
|
+
"cp",
|
|
85
|
+
"mkdir",
|
|
86
|
+
"touch",
|
|
87
|
+
"ln",
|
|
88
|
+
"chmod",
|
|
89
|
+
"chown",
|
|
90
|
+
"chgrp",
|
|
91
|
+
"tee",
|
|
92
|
+
"install",
|
|
93
|
+
"patch",
|
|
94
|
+
"truncate",
|
|
95
|
+
"npm",
|
|
96
|
+
"bun",
|
|
97
|
+
"pnpm",
|
|
98
|
+
"yarn",
|
|
99
|
+
"pip",
|
|
100
|
+
"pip3",
|
|
101
|
+
"cargo",
|
|
102
|
+
"make",
|
|
103
|
+
"apt",
|
|
104
|
+
"apt-get",
|
|
105
|
+
"brew",
|
|
106
|
+
"go",
|
|
107
|
+
"gradle",
|
|
108
|
+
"docker",
|
|
109
|
+
"kubectl",
|
|
110
|
+
"systemctl",
|
|
111
|
+
"service",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** Wrappers that execute a following command; classification follows the wrapped command. */
|
|
115
|
+
const COMMAND_WRAPPERS = new Set([
|
|
116
|
+
"sudo",
|
|
117
|
+
"env",
|
|
118
|
+
"command",
|
|
119
|
+
"nice",
|
|
120
|
+
"nohup",
|
|
121
|
+
"doas",
|
|
122
|
+
"time",
|
|
123
|
+
"timeout",
|
|
124
|
+
"stdbuf",
|
|
125
|
+
"setsid",
|
|
126
|
+
"ionice",
|
|
127
|
+
"exec",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const SEVERITY: RpcUnattendedActionClass[] = [
|
|
131
|
+
"bash.readonly",
|
|
132
|
+
"bash.mutating",
|
|
133
|
+
"file.write",
|
|
134
|
+
"file.delete",
|
|
135
|
+
"git.force_push",
|
|
136
|
+
"bash.destructive",
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
function worse(a: RpcUnattendedActionClass, b: RpcUnattendedActionClass): RpcUnattendedActionClass {
|
|
140
|
+
return SEVERITY.indexOf(a) >= SEVERITY.indexOf(b) ? a : b;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stripQuotes(token: string): string {
|
|
144
|
+
return token.replace(/^['"]+/, "").replace(/['"]+$/, "");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Extract inner command strings from $(...), <(...), >(...), and backticks. */
|
|
148
|
+
function extractNested(s: string): string[] {
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (let i = 0; i < s.length; i++) {
|
|
151
|
+
const two = s.slice(i, i + 2);
|
|
152
|
+
if (two === "$(" || two === "<(" || two === ">(") {
|
|
153
|
+
let depth = 0;
|
|
154
|
+
let j = i + 1;
|
|
155
|
+
for (; j < s.length; j++) {
|
|
156
|
+
if (s[j] === "(") depth++;
|
|
157
|
+
else if (s[j] === ")") {
|
|
158
|
+
depth--;
|
|
159
|
+
if (depth === 0) break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (j <= s.length) {
|
|
163
|
+
out.push(s.slice(i + 2, j));
|
|
164
|
+
i = j;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const backticks = s.match(/`([^`]*)`/g);
|
|
169
|
+
if (backticks) for (const b of backticks) out.push(b.slice(1, -1));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Replace nested substitutions with spaces so outer-statement parsing is clean. */
|
|
174
|
+
function stripNested(s: string): string {
|
|
175
|
+
let result = s.replace(/`[^`]*`/g, " ");
|
|
176
|
+
let prev: string;
|
|
177
|
+
do {
|
|
178
|
+
prev = result;
|
|
179
|
+
result = result.replace(/(\$\(|<\(|>\()[^()]*\)/g, " ");
|
|
180
|
+
} while (result !== prev);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function splitStatements(command: string): string[] {
|
|
185
|
+
return command
|
|
186
|
+
.split(/\n|&&|\|\||;|\||&/)
|
|
187
|
+
.map(s => s.trim())
|
|
188
|
+
.filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function tokenize(statement: string): string[] {
|
|
192
|
+
return statement.split(/\s+/).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Strip leading env-assignments and command wrappers; returns effective argv. */
|
|
196
|
+
function effectiveTokens(tokens: string[]): string[] {
|
|
197
|
+
let i = 0;
|
|
198
|
+
while (i < tokens.length) {
|
|
199
|
+
const raw = tokens[i] ?? "";
|
|
200
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(raw)) {
|
|
201
|
+
i++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const bare = stripQuotes(raw);
|
|
205
|
+
if (COMMAND_WRAPPERS.has(bare)) {
|
|
206
|
+
i++;
|
|
207
|
+
// Skip wrapper options and inline env-assignments (e.g. `env -i FOO=bar`).
|
|
208
|
+
while (i < tokens.length && (/^-/.test(tokens[i] ?? "") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] ?? ""))) {
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
return tokens.slice(i);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function classifyGit(rest: string[]): RpcUnattendedActionClass {
|
|
219
|
+
// Skip git global options (`-C dir`, `-c kv`, `--git-dir=…`) to find the subcommand.
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (i < rest.length && rest[i]?.startsWith("-")) {
|
|
222
|
+
const opt = rest[i] ?? "";
|
|
223
|
+
i++;
|
|
224
|
+
if ((opt === "-C" || opt === "-c") && i < rest.length) i++; // these take an argument
|
|
225
|
+
}
|
|
226
|
+
const sub = rest[i];
|
|
227
|
+
const args = rest.slice(i + 1);
|
|
228
|
+
if (sub === "push") {
|
|
229
|
+
const force = args.some(
|
|
230
|
+
a =>
|
|
231
|
+
a === "--force" ||
|
|
232
|
+
a === "-f" ||
|
|
233
|
+
a === "--force-with-lease" ||
|
|
234
|
+
a.startsWith("--force-with-lease=") ||
|
|
235
|
+
a === "--mirror" ||
|
|
236
|
+
a.startsWith("+"),
|
|
237
|
+
);
|
|
238
|
+
const del = args.some(a => a === "--delete" || a === "-d");
|
|
239
|
+
if (force || del) return "git.force_push";
|
|
240
|
+
return "bash.mutating"; // a normal push still mutates the remote
|
|
241
|
+
}
|
|
242
|
+
if ((sub === "reset" && args.includes("--hard")) || (sub === "clean" && args.some(a => /^-[a-z]*f/.test(a)))) {
|
|
243
|
+
return "bash.destructive";
|
|
244
|
+
}
|
|
245
|
+
// Fail-closed: only a known read-only subcommand stays readonly; everything
|
|
246
|
+
// else (clone, init, fetch, pull, commit, worktree, remote, branch -d, …) is
|
|
247
|
+
// treated as mutating so it cannot pass a readonly-only allowlist.
|
|
248
|
+
const READONLY_GIT = new Set([
|
|
249
|
+
"status",
|
|
250
|
+
"log",
|
|
251
|
+
"diff",
|
|
252
|
+
"show",
|
|
253
|
+
"rev-parse",
|
|
254
|
+
"ls-files",
|
|
255
|
+
"ls-remote",
|
|
256
|
+
"ls-tree",
|
|
257
|
+
"for-each-ref",
|
|
258
|
+
"describe",
|
|
259
|
+
"cat-file",
|
|
260
|
+
"blame",
|
|
261
|
+
"shortlog",
|
|
262
|
+
"whatchanged",
|
|
263
|
+
"grep",
|
|
264
|
+
"var",
|
|
265
|
+
"version",
|
|
266
|
+
"help",
|
|
267
|
+
]);
|
|
268
|
+
if (sub !== undefined && READONLY_GIT.has(sub)) {
|
|
269
|
+
// Even read-only subcommands can write a file via an output option.
|
|
270
|
+
if (args.map(stripQuotes).some(a => a === "--output" || a.startsWith("--output=") || a === "-o"))
|
|
271
|
+
return "file.write";
|
|
272
|
+
return "bash.readonly";
|
|
273
|
+
}
|
|
274
|
+
return "bash.mutating";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function classifyStatement(statement: string): RpcUnattendedActionClass {
|
|
278
|
+
// Redirection writes to a file (covers bare `> file` and `echo x >> f`).
|
|
279
|
+
let cls: RpcUnattendedActionClass = "bash.readonly";
|
|
280
|
+
// Output redirection to a FILE (incl. fd-qualified `1>f`, `2>>err`); excludes
|
|
281
|
+
// fd duplication like `2>&1` / `>&2` where `&` follows the `>`.
|
|
282
|
+
if (/\d*>>?\s*[^&>\s|]/.test(statement)) cls = worse(cls, "file.write");
|
|
283
|
+
|
|
284
|
+
const lower = statement.toLowerCase();
|
|
285
|
+
if (/\bmkfs\b/.test(lower) || /\bdd\b/.test(lower) || /:\(\)\s*\{/.test(statement) || /\bshred\b/.test(lower)) {
|
|
286
|
+
return "bash.destructive";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const tokens = effectiveTokens(tokenize(statement));
|
|
290
|
+
if (tokens.length === 0) return cls;
|
|
291
|
+
const head = stripQuotes(tokens[0] ?? "");
|
|
292
|
+
const rest = tokens.slice(1);
|
|
293
|
+
|
|
294
|
+
// `xargs CMD` runs CMD as the sink; classify the sink.
|
|
295
|
+
if (head === "xargs") {
|
|
296
|
+
const sinkTokens = rest.filter(t => !t.startsWith("-") && !/^-/.test(t));
|
|
297
|
+
if (sinkTokens.length > 0) return worse(cls, classifyStatement(sinkTokens.join(" ")));
|
|
298
|
+
return worse(cls, "bash.mutating");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (head === "git") return worse(cls, classifyGit(rest));
|
|
302
|
+
|
|
303
|
+
if (head === "rm" || head === "unlink" || head === "rmdir") return worse(cls, "file.delete");
|
|
304
|
+
if (
|
|
305
|
+
head === "find" &&
|
|
306
|
+
(rest.includes("-delete") || rest.includes("-exec") || rest.includes("-execdir") || rest.includes("-ok"))
|
|
307
|
+
) {
|
|
308
|
+
return worse(cls, "file.delete");
|
|
309
|
+
}
|
|
310
|
+
if (head === "sed" && rest.some(t => t === "-i" || t.startsWith("-i"))) return worse(cls, "file.write");
|
|
311
|
+
|
|
312
|
+
// Some "read-only" commands can still write/mutate via specific options/operands.
|
|
313
|
+
if (
|
|
314
|
+
head === "sort" &&
|
|
315
|
+
rest.some(a => a === "-o" || a.startsWith("-o") || a === "--output" || a.startsWith("--output="))
|
|
316
|
+
) {
|
|
317
|
+
return worse(cls, "file.write");
|
|
318
|
+
}
|
|
319
|
+
if (head === "uniq" && rest.filter(a => !a.startsWith("-")).length >= 2) return worse(cls, "file.write");
|
|
320
|
+
if (head === "date" && rest.some(a => !a.startsWith("-") && !a.startsWith("+"))) return worse(cls, "bash.mutating");
|
|
321
|
+
if (READONLY_COMMANDS.has(head)) return cls;
|
|
322
|
+
if (MUTATING_COMMANDS.has(head)) return worse(cls, "bash.mutating");
|
|
323
|
+
|
|
324
|
+
// Unknown command: conservatively require an explicit mutating allowance.
|
|
325
|
+
return worse(cls, "bash.mutating");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Classify a (possibly compound / nested) bash command into the most severe
|
|
330
|
+
* action class across all statements and nested substitutions. Fail-closed.
|
|
331
|
+
*/
|
|
332
|
+
export function classifyBashAction(command: string): RpcUnattendedActionClass {
|
|
333
|
+
let worst: RpcUnattendedActionClass = "bash.readonly";
|
|
334
|
+
for (const inner of extractNested(command)) {
|
|
335
|
+
worst = worse(worst, classifyBashAction(inner));
|
|
336
|
+
}
|
|
337
|
+
for (const stmt of splitStatements(stripNested(command))) {
|
|
338
|
+
worst = worse(worst, classifyStatement(stmt));
|
|
339
|
+
}
|
|
340
|
+
return worst;
|
|
341
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unattended-mode audit trail (#320).
|
|
3
|
+
*
|
|
4
|
+
* Append-only JSONL log of every auto-answered gate, budget breach, and
|
|
5
|
+
* scope/action denial in an unattended run. Records are deduped exactly-once by a
|
|
6
|
+
* stable `dedupe_key`, carry `schema_version`, and are queryable/exportable after
|
|
7
|
+
* the run by run/session/actor/gate/outcome and a time window.
|
|
8
|
+
*
|
|
9
|
+
* Answer policy: gate-response records store the full `answer` plus an
|
|
10
|
+
* `answer_hash` by default; when a redaction policy is enabled the raw answer is
|
|
11
|
+
* dropped and only the hash + a short summary is retained.
|
|
12
|
+
*/
|
|
13
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { RpcBudgetExceeded, RpcWorkflowGateKind, RpcWorkflowStage } from "../../rpc/rpc-types";
|
|
16
|
+
import { answerHashOf } from "./workflow-gate-schema";
|
|
17
|
+
|
|
18
|
+
export const AUDIT_SCHEMA_VERSION = 1;
|
|
19
|
+
export const AUDIT_CATEGORY = "unattended_lifecycle";
|
|
20
|
+
|
|
21
|
+
export type AuditOutcome = "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info";
|
|
22
|
+
|
|
23
|
+
export interface AuditRecord {
|
|
24
|
+
event_id: string;
|
|
25
|
+
schema_version: number;
|
|
26
|
+
category: typeof AUDIT_CATEGORY;
|
|
27
|
+
run_id: string;
|
|
28
|
+
session_id?: string;
|
|
29
|
+
actor?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
event: string;
|
|
32
|
+
outcome: AuditOutcome;
|
|
33
|
+
dedupe_key: string;
|
|
34
|
+
gate_id?: string;
|
|
35
|
+
stage?: RpcWorkflowStage;
|
|
36
|
+
kind?: RpcWorkflowGateKind;
|
|
37
|
+
scope?: string;
|
|
38
|
+
action?: string;
|
|
39
|
+
budget?: RpcBudgetExceeded;
|
|
40
|
+
/** Full answer (omitted when redaction is enabled). */
|
|
41
|
+
answer?: unknown;
|
|
42
|
+
answer_hash?: string;
|
|
43
|
+
error?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AuditQuery {
|
|
47
|
+
run_id?: string;
|
|
48
|
+
session_id?: string;
|
|
49
|
+
actor?: string;
|
|
50
|
+
gate_id?: string;
|
|
51
|
+
outcome?: AuditOutcome;
|
|
52
|
+
event?: string;
|
|
53
|
+
since?: string;
|
|
54
|
+
until?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AuditLogOptions {
|
|
58
|
+
/** When true, gate answers are stored as hash + summary only. */
|
|
59
|
+
redactAnswers?: boolean;
|
|
60
|
+
/** Injectable id/clock for deterministic tests. */
|
|
61
|
+
now?(): number;
|
|
62
|
+
nextId?(): string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let idCounter = 0;
|
|
66
|
+
|
|
67
|
+
function defaultId(): string {
|
|
68
|
+
idCounter += 1;
|
|
69
|
+
return `ae_${Date.now().toString(36)}_${idCounter.toString(36)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function defaultAuditPath(runId: string, root = process.cwd()): string {
|
|
73
|
+
const safe = runId.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
74
|
+
return path.join(root, ".gjc", "audit", "unattended", `${safe}.jsonl`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Append-only audit log writer + reader for one unattended run. */
|
|
78
|
+
export class UnattendedAuditLog {
|
|
79
|
+
private readonly seen = new Set<string>();
|
|
80
|
+
private readonly now: () => number;
|
|
81
|
+
private readonly nextId: () => string;
|
|
82
|
+
private readonly redactAnswers: boolean;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly filePath: string,
|
|
86
|
+
options: AuditLogOptions = {},
|
|
87
|
+
) {
|
|
88
|
+
this.now = options.now ?? Date.now;
|
|
89
|
+
this.nextId = options.nextId ?? defaultId;
|
|
90
|
+
this.redactAnswers = options.redactAnswers ?? false;
|
|
91
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
92
|
+
// Seed dedupe set from any existing records so restarts stay exactly-once.
|
|
93
|
+
for (const rec of this.readAll()) this.seen.add(rec.dedupe_key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Append a record. Returns the written record, or `null` if a record with the
|
|
98
|
+
* same dedupe_key was already written (exactly-once).
|
|
99
|
+
*/
|
|
100
|
+
record(input: Omit<AuditRecord, "event_id" | "schema_version" | "category" | "timestamp">): AuditRecord | null {
|
|
101
|
+
if (this.seen.has(input.dedupe_key)) return null;
|
|
102
|
+
const record: AuditRecord = {
|
|
103
|
+
event_id: this.nextId(),
|
|
104
|
+
schema_version: AUDIT_SCHEMA_VERSION,
|
|
105
|
+
category: AUDIT_CATEGORY,
|
|
106
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
107
|
+
...input,
|
|
108
|
+
};
|
|
109
|
+
if (this.redactAnswers && "answer" in record) {
|
|
110
|
+
record.answer = undefined;
|
|
111
|
+
}
|
|
112
|
+
// Durably append BEFORE recording the key as seen, so a failed write does not
|
|
113
|
+
// poison the dedupe set (which would wrongly skip a later retry of this event).
|
|
114
|
+
this.appendDurable(`${JSON.stringify(record)}\n`);
|
|
115
|
+
this.seen.add(record.dedupe_key);
|
|
116
|
+
return record;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Append one line and fsync it for crash durability. */
|
|
120
|
+
private appendDurable(line: string): void {
|
|
121
|
+
const fd = openSync(this.filePath, "a");
|
|
122
|
+
try {
|
|
123
|
+
writeSync(fd, line);
|
|
124
|
+
fsyncSync(fd);
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read every record (fail-closed: a corrupt line throws rather than silently dropping). */
|
|
131
|
+
readAll(): AuditRecord[] {
|
|
132
|
+
let raw: string;
|
|
133
|
+
try {
|
|
134
|
+
raw = readFileSync(this.filePath, "utf8");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
const out: AuditRecord[] = [];
|
|
140
|
+
const lines = raw.split("\n").filter(l => l.trim() !== "");
|
|
141
|
+
for (const [i, line] of lines.entries()) {
|
|
142
|
+
try {
|
|
143
|
+
out.push(JSON.parse(line) as AuditRecord);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
throw new Error(`corrupt audit record at ${this.filePath}:${i + 1}: ${(err as Error).message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Query records with filters (run/session/actor/gate/outcome/event + time window). */
|
|
152
|
+
query(filter: AuditQuery = {}): AuditRecord[] {
|
|
153
|
+
return this.readAll().filter(r => {
|
|
154
|
+
if (filter.run_id !== undefined && r.run_id !== filter.run_id) return false;
|
|
155
|
+
if (filter.session_id !== undefined && r.session_id !== filter.session_id) return false;
|
|
156
|
+
if (filter.actor !== undefined && r.actor !== filter.actor) return false;
|
|
157
|
+
if (filter.gate_id !== undefined && r.gate_id !== filter.gate_id) return false;
|
|
158
|
+
if (filter.outcome !== undefined && r.outcome !== filter.outcome) return false;
|
|
159
|
+
if (filter.event !== undefined && r.event !== filter.event) return false;
|
|
160
|
+
if (filter.since !== undefined && r.timestamp < filter.since) return false;
|
|
161
|
+
if (filter.until !== undefined && r.timestamp > filter.until) return false;
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Export the full trail as an array (for `get_unattended_audit`). */
|
|
167
|
+
export(filter: AuditQuery = {}): AuditRecord[] {
|
|
168
|
+
return this.query(filter);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** SHA-256 of the canonical JSON of an answer (matches the gate broker's hash). */
|
|
173
|
+
export function answerHash(answer: unknown): string {
|
|
174
|
+
return answerHashOf(answer);
|
|
175
|
+
}
|