@kylecheng3146/agent-ops 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -6
- package/dist/packages/cli/src/args.js +1 -1
- package/dist/packages/cli/src/bin.js +2 -0
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/codex-loop-process.js +70 -0
- package/dist/packages/cli/src/commands/hook.js +16 -1
- package/dist/packages/cli/src/commands/update.js +3 -0
- package/dist/packages/cli/src/context.js +60 -0
- package/dist/packages/cli/src/hook-process.js +128 -15
- package/dist/packages/cli/src/loop-entry.js +8 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +9 -4
- package/dist/runtime/src/adapters/claude/config.js +57 -11
- package/dist/runtime/src/adapters/claude/events.js +7 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/adapters/codex/config.js +39 -4
- package/dist/runtime/src/adapters/codex/events.js +7 -0
- package/dist/runtime/src/fs/managed-block.js +35 -18
- package/dist/runtime/src/hooks/codex-loop.js +439 -0
- package/dist/runtime/src/install/codex-loop.js +139 -0
- package/dist/runtime/src/install/doctor.js +66 -8
- package/dist/runtime/src/install/harness.js +8 -10
- package/dist/runtime/src/install/ownership.js +37 -2
- package/dist/runtime/src/install/plan.js +70 -4
- package/dist/runtime/src/install/profiles.js +5 -3
- package/dist/runtime/src/install/uninstall.js +1 -1
- package/dist/runtime/src/install/update.js +5 -1
- package/dist/runtime/src/logging/local-log.js +25 -0
- package/dist/runtime/src/schema/validate.js +8 -1
- package/docs/en/guides/configuration.md +78 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/zh-TW/guides/configuration.md +73 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/package.json +1 -1
- package/schemas/config.schema.json +1 -1
- package/schemas/manifest.schema.json +12 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ installation plan.
|
|
|
35
35
|
|
|
36
36
|
The interactive multi-select screens start with no harness or profile selected.
|
|
37
37
|
Choose at least one of `codex`, `claude`, and `opencode`, and at least one of
|
|
38
|
-
the `core`, `advisory`, and `
|
|
38
|
+
the `core`, `advisory`, `guardrails`, and `loop` profiles before confirming. For
|
|
39
39
|
scripted use, `--harness all` selects all three harnesses; comma-separated
|
|
40
40
|
selections such as `codex,opencode` are also supported. The legacy `both` value
|
|
41
41
|
remains an alias for `codex,claude`.
|
|
@@ -64,6 +64,54 @@ agent-ops update --yes --json
|
|
|
64
64
|
agent-ops uninstall --dry-run --json
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
### Project-local loop
|
|
68
|
+
|
|
69
|
+
`loop` is an explicit, project-only profile for Codex, Claude Code, or both.
|
|
70
|
+
It requires a POSIX-compatible `bash`; the generated native launchers are
|
|
71
|
+
`.sh` files, so Windows is not currently a supported loop host. Preview it
|
|
72
|
+
first, then install only after reviewing the plan. `loop` also implies the
|
|
73
|
+
`core` baseline, so the project retains the managed rules and routing files:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
agent-ops init --dry-run --scope project --harness codex,claude --profile loop --json
|
|
77
|
+
agent-ops init --scope project --harness codex,claude --profile loop --yes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It creates one small managed launcher per selected native host:
|
|
81
|
+
|
|
82
|
+
- Codex: `.codex/hooks/agent-ops-loop.sh`, `.codex/config.toml` when absent,
|
|
83
|
+
`.codex/loop-goal.md`, `.codex/loop-state.md`, and
|
|
84
|
+
`.codex/loop-telemetry.jsonl`.
|
|
85
|
+
- Claude Code: `.claude/hooks/agent-ops-loop.sh`, `.claude/loop-goal.md`,
|
|
86
|
+
`.claude/loop-state.md`, and `.claude/loop-telemetry.jsonl`.
|
|
87
|
+
|
|
88
|
+
The launchers delegate to one installed Node runtime; agent-ops does not copy
|
|
89
|
+
project-specific policy code into either hook directory. It adds an exact,
|
|
90
|
+
hash-commented `.gitignore` block for the goal, state, and telemetry files.
|
|
91
|
+
Those files and `.codex/config.toml` remain user-owned: update never overwrites
|
|
92
|
+
them, and uninstall keeps them while removing only launchers, hook handlers,
|
|
93
|
+
and the managed ignore block.
|
|
94
|
+
|
|
95
|
+
The loop registers `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
|
|
96
|
+
`PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`,
|
|
97
|
+
`SubagentStart`, and `SubagentStop`; it intentionally does not register
|
|
98
|
+
`Stop`. On high-confidence matches, it blocks literal credential-shaped user
|
|
99
|
+
prompts or Bash commands, plus dangerous Bash commands such as broad recursive
|
|
100
|
+
deletion or `git reset --hard`. Codex uses its documented exit-code denial path; Claude Code
|
|
101
|
+
uses its documented native JSON denial shapes. Permission and escalation
|
|
102
|
+
requests are never auto-approved or denied by the loop, so the harness's normal
|
|
103
|
+
approval prompt remains authoritative.
|
|
104
|
+
|
|
105
|
+
Session context is bounded and derives from the redacted goal plus a telemetry
|
|
106
|
+
count. Telemetry records only timestamp, event, outcome, and rule code; it
|
|
107
|
+
never stores raw prompts, Bash commands, or credentials and is byte-rotated.
|
|
108
|
+
Before compaction, the loop writes a bounded, redacted Git-status snapshot into
|
|
109
|
+
its own block inside `loop-state.md`, preserving surrounding user text. This is
|
|
110
|
+
a guardrail, not a complete sandbox or a replacement for each harness's own
|
|
111
|
+
permissions. Review and trust the generated hook configuration in Codex and
|
|
112
|
+
Claude Code before use. An existing Codex `.codex/config.toml` with an explicit
|
|
113
|
+
`[features]` / `hooks = false` setting stops installation before any write.
|
|
114
|
+
|
|
67
115
|
Use `--scope user` with user-home installations. Keep `--dry-run` for any
|
|
68
116
|
operation you want to inspect before applying; non-interactive automation should
|
|
69
117
|
pass `--yes` only after reviewing the plan. Add `--json` when another tool will
|
|
@@ -121,6 +169,12 @@ The commands after `init --yes` are post-apply operations. `doctor` reports
|
|
|
121
169
|
`trust grant` runs, and `smoke-availability` until the configuration declares a
|
|
122
170
|
verification command.
|
|
123
171
|
|
|
172
|
+
`artifact-staleness` reports `DEGRADED` with `UPDATE_REQUIRED` when a toolkit
|
|
173
|
+
upgrade or effective profile or capability change makes intact managed rules
|
|
174
|
+
differ from the current baseline. Run `agent-ops update` to regenerate them.
|
|
175
|
+
Missing, altered, or hash-mismatched managed artifacts remain `FAIL` under
|
|
176
|
+
`artifacts`.
|
|
177
|
+
|
|
124
178
|
Installing the `advisory` or `guardrails` profile registers the lifecycle and
|
|
125
179
|
command-policy hooks implied by those profiles for the selected harnesses.
|
|
126
180
|
Claude Code and Codex use their native JSON settings files; opencode uses the agent-ops-owned
|
|
@@ -132,11 +186,24 @@ scope, the opencode plugin is placed under
|
|
|
132
186
|
`$XDG_CONFIG_HOME/opencode/` or `$OPENCODE_CONFIG_DIR/` when that location is
|
|
133
187
|
inside the managed user root. The shims call `agent-ops hook <harness> <event>`
|
|
134
188
|
through the installed absolute runtime path. Advisory failures remain
|
|
135
|
-
fail-open
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
189
|
+
fail-open. Runtime-failure enforcement is deliberately narrow: Claude Code can
|
|
190
|
+
emit its documented `PreToolUse` denial shape for a classified invalid installed
|
|
191
|
+
configuration, and the managed OpenCode `tool.execute.before` plugin throws
|
|
192
|
+
its documented command-policy denial or unavailable-runtime error for its
|
|
193
|
+
supported Bash surface. Codex command policy is `unknown` and explicitly
|
|
194
|
+
non-enforcing for the ordinary `guardrails` profile. The project-local `loop`
|
|
195
|
+
profile uses its separate native hook policy described above. These are output
|
|
196
|
+
and plugin contracts, not proof that a host
|
|
197
|
+
honors a denial. Claude and Codex lifecycle summaries are `supported`; OpenCode's
|
|
198
|
+
app-scoped initialization is `degraded` rather than per-session coverage.
|
|
199
|
+
|
|
200
|
+
Claude's invalid-config fallback has four safeguards: only an invalid (not
|
|
201
|
+
absent) `.agent-ops/config.json` can reach it; a safely read manifest must list
|
|
202
|
+
the current harness; `AGENT_OPS_DISABLE=1` must not be set; and Claude's denial
|
|
203
|
+
reason names the config file with a repair or temporary shell-disable remedy.
|
|
204
|
+
`AGENT_OPS_DISABLE=1` is a human-shell recovery variable only. Agent-ops never
|
|
205
|
+
reads it from configuration, a manifest, or another file it writes. Every
|
|
206
|
+
`SessionStart` and `Stop` failure path remains fail-open.
|
|
140
207
|
|
|
141
208
|
`guardrails` enables command policy only; it does not imply Stop verification.
|
|
142
209
|
Stop verification is an explicit, disabled-by-default config feature. Enable it
|
|
@@ -173,6 +240,15 @@ files authoritative. Existing installations with the previous canonical
|
|
|
173
240
|
wording are migrated by `agent-ops update`; changed managed blocks still fail
|
|
174
241
|
closed.
|
|
175
242
|
|
|
243
|
+
### Rejected proposals and deliberate boundaries
|
|
244
|
+
|
|
245
|
+
The following proposals are deliberately rejected: emitting a model-visible
|
|
246
|
+
`SessionStart` advisory summary, inspecting user-authored Markdown link
|
|
247
|
+
integrity in `doctor`, and creating backups for agent-authored rule edits.
|
|
248
|
+
Transactional backups remain limited to agent-ops apply operations. Agent-ops
|
|
249
|
+
also does not add a git-workflow instruction to the generated baseline, which
|
|
250
|
+
stays project-neutral.
|
|
251
|
+
|
|
176
252
|
Dry-run plans keep harness settings writes opaque: human and JSON output expose
|
|
177
253
|
only the expected hash, content hash, and a safe summary. Use
|
|
178
254
|
`--hook-target <harness>=<surface-id>` when selecting a non-default discovered
|
|
@@ -12,7 +12,7 @@ export const COMMAND_NAMES = [
|
|
|
12
12
|
];
|
|
13
13
|
const COMMAND_SET = new Set(COMMAND_NAMES);
|
|
14
14
|
const SCOPES = new Set(["project", "user"]);
|
|
15
|
-
const PROFILES = new Set(["advisory", "core", "guardrails"]);
|
|
15
|
+
const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
16
16
|
export class CliArgumentError extends Error {
|
|
17
17
|
code;
|
|
18
18
|
option;
|
|
@@ -131,6 +131,7 @@ else {
|
|
|
131
131
|
const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
132
132
|
return await runDoctorCommand({
|
|
133
133
|
root,
|
|
134
|
+
toolkitVersion: CLI_VERSION,
|
|
134
135
|
probes: {
|
|
135
136
|
hookRegistration: async () => hookRegistrationSatisfied({
|
|
136
137
|
harness: await installedHarness(root),
|
|
@@ -157,6 +158,7 @@ else {
|
|
|
157
158
|
adapters: commonHarnessAdapters(),
|
|
158
159
|
registry: new NpmRegistryClient(),
|
|
159
160
|
isTTY,
|
|
161
|
+
toolkitVersion: CLI_VERSION,
|
|
160
162
|
hookRuntimePath: HOOK_RUNTIME_PATH,
|
|
161
163
|
...(args.hookTargets === undefined
|
|
162
164
|
? {}
|
|
@@ -31,7 +31,7 @@ Options:
|
|
|
31
31
|
--scope <project|user>
|
|
32
32
|
--harness <all|both|claude|codex|opencode|comma-separated> Init/update
|
|
33
33
|
--hook-target <harness=surface-id> Repeatable advanced init/update option
|
|
34
|
-
--profile <core|advisory|guardrails> Repeatable
|
|
34
|
+
--profile <core|advisory|guardrails|loop> Repeatable
|
|
35
35
|
--task <id>
|
|
36
36
|
--target-version <version> Update target version (offline-capable)
|
|
37
37
|
--title <text>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { PROJECT_LOOP_EVENTS, runProjectLoop } from "../../../runtime/src/hooks/codex-loop.js";
|
|
2
|
+
const MAX_LOOP_INPUT_BYTES = 64 * 1024;
|
|
3
|
+
function isLoopHarness(value) {
|
|
4
|
+
return value === "claude" || value === "codex";
|
|
5
|
+
}
|
|
6
|
+
function isLoopEvent(value) {
|
|
7
|
+
return (value !== undefined &&
|
|
8
|
+
PROJECT_LOOP_EVENTS.includes(value));
|
|
9
|
+
}
|
|
10
|
+
async function readStdin(stream) {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let total = 0;
|
|
13
|
+
for await (const chunk of stream) {
|
|
14
|
+
const buffer = Buffer.isBuffer(chunk)
|
|
15
|
+
? chunk
|
|
16
|
+
: Buffer.from(String(chunk), "utf8");
|
|
17
|
+
total += buffer.byteLength;
|
|
18
|
+
if (total > MAX_LOOP_INPUT_BYTES) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
chunks.push(buffer);
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
24
|
+
}
|
|
25
|
+
function parseInput(source) {
|
|
26
|
+
if (source === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(source);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Process boundary for the generated Bash launchers. Invalid input stays
|
|
38
|
+
* fail-open; the runtime owns every policy decision and output shape.
|
|
39
|
+
*/
|
|
40
|
+
export async function runLoopProcess(argv, io, dependencies = {}) {
|
|
41
|
+
const [harness, event] = argv;
|
|
42
|
+
if (!isLoopHarness(harness) || !isLoopEvent(event)) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const result = await runProjectLoop({
|
|
47
|
+
harness,
|
|
48
|
+
event,
|
|
49
|
+
input: parseInput(await readStdin(io.stdin)),
|
|
50
|
+
root: dependencies.root ?? process.cwd(),
|
|
51
|
+
...(dependencies.now === undefined ? {} : { now: dependencies.now }),
|
|
52
|
+
...(dependencies.gitStatus === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: { gitStatus: dependencies.gitStatus }),
|
|
55
|
+
...(dependencies.telemetryMaxBytes === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { telemetryMaxBytes: dependencies.telemetryMaxBytes })
|
|
58
|
+
});
|
|
59
|
+
if (result.stdout.length > 0) {
|
|
60
|
+
io.writeStdout(result.stdout);
|
|
61
|
+
}
|
|
62
|
+
if (result.stderr.length > 0) {
|
|
63
|
+
io.writeStderr(result.stderr);
|
|
64
|
+
}
|
|
65
|
+
return result.exitCode;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -7,6 +7,18 @@ export const HOOK_EVENTS = [
|
|
|
7
7
|
"PreToolUse",
|
|
8
8
|
"Stop"
|
|
9
9
|
];
|
|
10
|
+
/**
|
|
11
|
+
* Keep adapter normalization at the command boundary so exceptional hook
|
|
12
|
+
* paths use the same native-event interpretation as normal dispatch.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeHookInput(harness, input) {
|
|
15
|
+
try {
|
|
16
|
+
return harnessDescriptor(harness).runtime.normalizeInput(input);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
10
22
|
/**
|
|
11
23
|
* Hooks are advisory infrastructure: every failure path stays fail-open with
|
|
12
24
|
* exit code 0 so a broken toolkit can never wedge the harness.
|
|
@@ -24,7 +36,10 @@ export async function runHookCommand(options) {
|
|
|
24
36
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
25
37
|
}
|
|
26
38
|
const descriptor = harnessDescriptor(options.harness);
|
|
27
|
-
const normalized =
|
|
39
|
+
const normalized = normalizeHookInput(options.harness, input);
|
|
40
|
+
if (normalized === null) {
|
|
41
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
42
|
+
}
|
|
28
43
|
const stopRegistration = descriptor.control.registrations.find(({ capability }) => capability === "optional-stop-verify");
|
|
29
44
|
const stopVerification = options.stopVerification !== undefined &&
|
|
30
45
|
stopRegistration?.support !== "unsupported"
|
|
@@ -38,6 +38,9 @@ export async function runUpdateCommand(options) {
|
|
|
38
38
|
...(options.targetVersion === undefined
|
|
39
39
|
? {}
|
|
40
40
|
: { targetVersion: options.targetVersion }),
|
|
41
|
+
...(options.toolkitVersion === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { toolkitVersion: options.toolkitVersion }),
|
|
41
44
|
...(options.hookRuntimePath === undefined
|
|
42
45
|
? {}
|
|
43
46
|
: { hookRuntimePath: options.hookRuntimePath }),
|
|
@@ -79,6 +79,66 @@ export async function loadEffectiveConfig(root, scope) {
|
|
|
79
79
|
}
|
|
80
80
|
return mergeConfigLayers(layers);
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Classifies only configuration that can participate in a project hook. The
|
|
84
|
+
* regular command loader intentionally keeps its throwing contract so CLI
|
|
85
|
+
* commands continue to surface configuration errors to the human.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadProjectHookConfig(root) {
|
|
88
|
+
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
89
|
+
const userPath = join(home, ".agent-ops", "config.json");
|
|
90
|
+
const projectPath = join(root, ".agent-ops", "config.json");
|
|
91
|
+
const layers = [defaultConfigLayer()];
|
|
92
|
+
let project;
|
|
93
|
+
try {
|
|
94
|
+
project = await loadOptionalConfig(projectPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { kind: "invalid", path: projectPath };
|
|
98
|
+
}
|
|
99
|
+
if (project === null && projectPath === userPath) {
|
|
100
|
+
return { kind: "absent", config: DEFAULT_CONFIG };
|
|
101
|
+
}
|
|
102
|
+
if (projectPath !== userPath) {
|
|
103
|
+
try {
|
|
104
|
+
const user = await loadOptionalConfig(userPath);
|
|
105
|
+
if (user !== null) {
|
|
106
|
+
layers.push({
|
|
107
|
+
source: "user",
|
|
108
|
+
sourcePath: user.sourcePath,
|
|
109
|
+
config: user.config
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A user-level error cannot make a project with no own config deny a
|
|
115
|
+
// tool call, but it remains a classified failure for an installed
|
|
116
|
+
// project that does own a config.
|
|
117
|
+
return project === null
|
|
118
|
+
? { kind: "absent", config: DEFAULT_CONFIG }
|
|
119
|
+
: { kind: "invalid", path: userPath };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (project === null) {
|
|
123
|
+
try {
|
|
124
|
+
return { kind: "absent", config: mergeConfigLayers(layers).config };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { kind: "absent", config: DEFAULT_CONFIG };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
layers.push({
|
|
131
|
+
source: "project",
|
|
132
|
+
sourcePath: project.sourcePath,
|
|
133
|
+
config: project.config
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
return { kind: "loaded", config: mergeConfigLayers(layers).config };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { kind: "invalid", path: projectPath };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
82
142
|
export function repositoryRemoteUrl(root) {
|
|
83
143
|
try {
|
|
84
144
|
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { promisify } from "node:util";
|
|
3
6
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
7
|
+
import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../../../runtime/src/fs/manifest.js";
|
|
8
|
+
import { resolveContainedPath } from "../../../runtime/src/fs/paths.js";
|
|
9
|
+
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
4
10
|
import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
|
|
5
11
|
import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
|
|
6
12
|
import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
|
|
7
13
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
8
|
-
import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
9
|
-
import {
|
|
14
|
+
import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
15
|
+
import { loadProjectHookConfig, repositoryTrust } from "./context.js";
|
|
10
16
|
const HARNESSES = new Set(HARNESS_IDS);
|
|
11
17
|
const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
|
|
18
|
+
const MAX_HOOK_MANIFEST_BYTES = 1024 * 1024;
|
|
12
19
|
const execFile = promisify(execFileCallback);
|
|
13
20
|
async function readStdin(stream) {
|
|
14
21
|
const chunks = [];
|
|
@@ -33,6 +40,99 @@ function parseInput(source) {
|
|
|
33
40
|
return null;
|
|
34
41
|
}
|
|
35
42
|
}
|
|
43
|
+
function writeHookOutput(io, output) {
|
|
44
|
+
if (output.stdout.length > 0) {
|
|
45
|
+
io.writeStdout(output.stdout);
|
|
46
|
+
}
|
|
47
|
+
if (output.stderr.length > 0) {
|
|
48
|
+
io.writeStderr(output.stderr);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function failOpenOutput(harness, event) {
|
|
52
|
+
return harnessDescriptor(harness).runtime.formatOutput(event, {
|
|
53
|
+
action: "continue",
|
|
54
|
+
status: "PASS",
|
|
55
|
+
code: "HOOK_FAIL_OPEN"
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function readInstalledManifestForHarness(root, harness) {
|
|
59
|
+
try {
|
|
60
|
+
const path = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
|
|
61
|
+
const before = await lstat(path, { bigint: true });
|
|
62
|
+
if (!before.isFile() ||
|
|
63
|
+
before.size > BigInt(MAX_HOOK_MANIFEST_BYTES)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
67
|
+
try {
|
|
68
|
+
const opened = await handle.stat({ bigint: true });
|
|
69
|
+
const resolvedAgain = await resolveContainedPath(root, PROJECT_MANIFEST_PATH);
|
|
70
|
+
const after = await lstat(resolvedAgain, { bigint: true });
|
|
71
|
+
if (!opened.isFile() ||
|
|
72
|
+
opened.size > BigInt(MAX_HOOK_MANIFEST_BYTES) ||
|
|
73
|
+
after.dev !== before.dev ||
|
|
74
|
+
after.ino !== before.ino) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
while (totalBytes <= MAX_HOOK_MANIFEST_BYTES) {
|
|
80
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HOOK_MANIFEST_BYTES + 1 - totalBytes));
|
|
81
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
82
|
+
if (bytesRead === 0) {
|
|
83
|
+
return parseInstallManifest(Buffer.concat(chunks, totalBytes).toString("utf8")).harness.includes(harness);
|
|
84
|
+
}
|
|
85
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
86
|
+
totalBytes += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function hookConfigOutcome(root, loadConfig) {
|
|
99
|
+
if (loadConfig === undefined) {
|
|
100
|
+
return await loadProjectHookConfig(root);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return { kind: "loaded", config: await loadConfig(root) };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return {
|
|
107
|
+
kind: "invalid",
|
|
108
|
+
path: join(root, ".agent-ops", "config.json")
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function invalidConfigOutput(options) {
|
|
113
|
+
const descriptor = harnessDescriptor(options.harness);
|
|
114
|
+
const normalized = normalizeHookInput(options.harness, options.input);
|
|
115
|
+
if (normalized === null) {
|
|
116
|
+
return failOpenOutput(options.harness, options.event);
|
|
117
|
+
}
|
|
118
|
+
if (options.event !== "PreToolUse" ||
|
|
119
|
+
normalized.event !== "command" ||
|
|
120
|
+
!(await readInstalledManifestForHarness(options.root, options.harness))) {
|
|
121
|
+
return failOpenOutput(options.harness, options.event);
|
|
122
|
+
}
|
|
123
|
+
const registration = descriptor.control.registrations.find(({ nativeEvent, normalizedEvent }) => nativeEvent === options.event && normalizedEvent === normalized.event);
|
|
124
|
+
if (registration === undefined || registration.runtimeFailure === "fail-open") {
|
|
125
|
+
return failOpenOutput(options.harness, options.event);
|
|
126
|
+
}
|
|
127
|
+
if (options.harness === "opencode" &&
|
|
128
|
+
registration.runtimeFailure === "fail-closed") {
|
|
129
|
+
// The generated OpenCode plugin already turns an unavailable runtime into
|
|
130
|
+
// its documented blocking error. Keep that contract in the shim.
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const remedy = `Fix ${redactSecrets(options.configPath)}, or set AGENT_OPS_DISABLE=1 in your shell to temporarily disable agent-ops.`;
|
|
134
|
+
return descriptor.runtime.formatRuntimeFailure(options.event, registration.capability, remedy);
|
|
135
|
+
}
|
|
36
136
|
function defaultGitRunner(root) {
|
|
37
137
|
return {
|
|
38
138
|
run: async (args) => {
|
|
@@ -109,18 +209,36 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
109
209
|
}
|
|
110
210
|
try {
|
|
111
211
|
const root = dependencies.root ?? process.cwd();
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
212
|
+
const harnessId = harness;
|
|
213
|
+
const hookEvent = event;
|
|
214
|
+
if (process.env.AGENT_OPS_DISABLE === "1") {
|
|
215
|
+
writeHookOutput(io, failOpenOutput(harnessId, hookEvent));
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const rawInput = await readStdin(io.stdin);
|
|
219
|
+
const parsedInput = parseInput(rawInput);
|
|
220
|
+
const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
221
|
+
if (configOutcome.kind === "invalid") {
|
|
222
|
+
const output = await invalidConfigOutput({
|
|
223
|
+
root,
|
|
224
|
+
harness: harnessId,
|
|
225
|
+
event: hookEvent,
|
|
226
|
+
input: parsedInput,
|
|
227
|
+
configPath: configOutcome.path
|
|
228
|
+
});
|
|
229
|
+
if (output !== undefined) {
|
|
230
|
+
writeHookOutput(io, output);
|
|
231
|
+
}
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
const config = configOutcome.config;
|
|
115
235
|
const trustStatus = dependencies.trust === undefined
|
|
116
236
|
? await repositoryTrust(root, config, cliVersion)
|
|
117
237
|
: await dependencies.trust(root, config, cliVersion);
|
|
118
|
-
const rawInput = await readStdin(io.stdin);
|
|
119
|
-
const parsedInput = parseInput(rawInput);
|
|
120
238
|
const trusted = trustStatus === "TRUSTED";
|
|
121
239
|
const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
|
|
122
240
|
const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
|
|
123
|
-
const stopVerification = shouldBuildStopVerification(
|
|
241
|
+
const stopVerification = shouldBuildStopVerification(harnessId, event, config, parsedInput)
|
|
124
242
|
? stopVerificationOptions({
|
|
125
243
|
harness: harness,
|
|
126
244
|
rawInput: parsedInput,
|
|
@@ -133,7 +251,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
133
251
|
: undefined;
|
|
134
252
|
const output = await runHookCommand({
|
|
135
253
|
harness: harness,
|
|
136
|
-
event:
|
|
254
|
+
event: hookEvent,
|
|
137
255
|
stdin: rawInput,
|
|
138
256
|
config,
|
|
139
257
|
trusted,
|
|
@@ -142,12 +260,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
142
260
|
: { advisory: dependencies.advisory }),
|
|
143
261
|
...(stopVerification === undefined ? {} : { stopVerification })
|
|
144
262
|
});
|
|
145
|
-
|
|
146
|
-
io.writeStdout(output.stdout);
|
|
147
|
-
}
|
|
148
|
-
if (output.stderr.length > 0) {
|
|
149
|
-
io.writeStderr(output.stderr);
|
|
150
|
-
}
|
|
263
|
+
writeHookOutput(io, output);
|
|
151
264
|
}
|
|
152
265
|
catch {
|
|
153
266
|
// ponytail: fail-open by design; hook failures stay invisible to the harness.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point used only by the generated project-local loop launchers.
|
|
3
|
+
import { runLoopProcess } from "./codex-loop-process.js";
|
|
4
|
+
process.exitCode = await runLoopProcess(process.argv.slice(2), {
|
|
5
|
+
stdin: process.stdin,
|
|
6
|
+
writeStdout: (value) => process.stdout.write(value),
|
|
7
|
+
writeStderr: (value) => process.stderr.write(value)
|
|
8
|
+
});
|
|
@@ -2,7 +2,7 @@ import { CliArgumentError } from "./args.js";
|
|
|
2
2
|
import { HARNESS_IDS, resolveHarnessSelection } from "../../../runtime/src/install/harness.js";
|
|
3
3
|
import { selectOption, selectOptions } from "./ui.js";
|
|
4
4
|
const SCOPES = new Set(["project", "user"]);
|
|
5
|
-
const PROFILES = new Set(["advisory", "core", "guardrails"]);
|
|
5
|
+
const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
6
6
|
const DEFAULT_HARNESS = [];
|
|
7
7
|
const SCOPE_CHOICES = [
|
|
8
8
|
{ label: "project", value: "project" },
|
|
@@ -24,6 +24,11 @@ const PROFILE_CHOICES = [
|
|
|
24
24
|
label: "guardrails",
|
|
25
25
|
value: "guardrails",
|
|
26
26
|
description: "Blocks high-confidence unsafe commands; Stop verification is a separate opt-in feature."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: "loop",
|
|
30
|
+
value: "loop",
|
|
31
|
+
description: "Installs the shared Codex and Claude Code local loop with secret and destructive-command interception."
|
|
27
32
|
}
|
|
28
33
|
];
|
|
29
34
|
const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
@@ -74,7 +79,7 @@ function selectProfiles(raw) {
|
|
|
74
79
|
if (values.length === 0 ||
|
|
75
80
|
values.some((value) => !PROFILES.has(value)) ||
|
|
76
81
|
new Set(values).size !== values.length) {
|
|
77
|
-
throw new CliArgumentError("CLI_INVALID_VALUE", "Profiles must be a unique comma-separated list of core, advisory, or
|
|
82
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", "Profiles must be a unique comma-separated list of core, advisory, guardrails, or loop.");
|
|
78
83
|
}
|
|
79
84
|
return values;
|
|
80
85
|
}
|
|
@@ -110,7 +115,7 @@ export async function completeInitChoices(args, io) {
|
|
|
110
115
|
: await selectOptions("Profiles (multi-select: ↑↓ move, Space toggle, Enter confirm)", PROFILE_CHOICES, selectorIo, [], {
|
|
111
116
|
selectAll: true,
|
|
112
117
|
selectAllLabel: "Select all",
|
|
113
|
-
selectAllDescription: "Enable core, advisory, and
|
|
118
|
+
selectAllDescription: "Enable core, advisory, guardrails, and loop together."
|
|
114
119
|
});
|
|
115
120
|
return {
|
|
116
121
|
...args,
|
|
@@ -127,7 +132,7 @@ export async function completeInitChoices(args, io) {
|
|
|
127
132
|
selectHarness(await session.question(`Harness (${HARNESS_IDS.join(",")}): `));
|
|
128
133
|
const profiles = args.profiles.length > 0
|
|
129
134
|
? args.profiles
|
|
130
|
-
: selectProfiles(await session.question("Profiles (core,advisory,guardrails) [core]: "));
|
|
135
|
+
: selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
|
|
131
136
|
return {
|
|
132
137
|
...args,
|
|
133
138
|
scope,
|