@agentspec/claude-plugin 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/.claude-plugin/plugin.json +12 -0
- package/.mcp.json +8 -0
- package/LICENSE +183 -0
- package/README.md +100 -0
- package/dist/bridge.js +375 -0
- package/dist/bridge.js.map +1 -0
- package/dist/statusline.js +56 -0
- package/dist/statusline.js.map +1 -0
- package/hooks/hooks.json +64 -0
- package/hooks/post-tool-use.mjs +38 -0
- package/hooks/pre-tool-use.mjs +64 -0
- package/hooks/session-end.mjs +13 -0
- package/hooks/session-start.mjs +37 -0
- package/hooks/subagent-stop.mjs +33 -0
- package/package.json +50 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentspec",
|
|
3
|
+
"displayName": "AgentSpec",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Live spec↔runtime bridge, manifest-driven policy enforcement, and a health status line for Claude Code — driven by your agent.yaml.",
|
|
6
|
+
"author": { "name": "AgentSpec", "url": "https://agentspec.io" },
|
|
7
|
+
"homepage": "https://agentspec.io",
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"keywords": ["agentspec", "governance", "observability", "hooks"],
|
|
10
|
+
"hooks": "./hooks/hooks.json",
|
|
11
|
+
"mcpServers": "./.mcp.json"
|
|
12
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship made available under
|
|
36
|
+
the License, as indicated by a copyright notice that is included in
|
|
37
|
+
or attached to the work (an example is provided in the Appendix below).
|
|
38
|
+
|
|
39
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
40
|
+
form, that is based on (or derived from) the Work and for which the
|
|
41
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
42
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
43
|
+
of this License, Derivative Works shall not include works that remain
|
|
44
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
45
|
+
the Work and Derivative Works thereof.
|
|
46
|
+
|
|
47
|
+
"Contribution" shall mean, as submitted to the Licensor for inclusion
|
|
48
|
+
in the Work by the copyright owner or by an individual or Legal Entity
|
|
49
|
+
authorized to submit on behalf of the copyright owner. For the purposes
|
|
50
|
+
of this definition, "submit" means any form of electronic, verbal, or
|
|
51
|
+
written communication sent to the Licensor or its representatives,
|
|
52
|
+
including but not limited to communication on electronic mailing lists,
|
|
53
|
+
source code control systems, and issue tracking systems that are managed
|
|
54
|
+
by, or on behalf of, the Licensor for the purpose of discussing and
|
|
55
|
+
improving the Work, but excluding communication that is conspicuously
|
|
56
|
+
marked or designated in writing by the copyright owner as "Not a
|
|
57
|
+
Contribution."
|
|
58
|
+
|
|
59
|
+
"Contributor" shall mean Licensor and any Legal Entity on behalf of
|
|
60
|
+
whom a Contribution has been received by the Licensor and included
|
|
61
|
+
within the Work.
|
|
62
|
+
|
|
63
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
64
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
65
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
66
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
67
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
68
|
+
Work and such Derivative Works in Source or Object form.
|
|
69
|
+
|
|
70
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
71
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
72
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
73
|
+
(except as stated in this section) patent license to make, have made,
|
|
74
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
75
|
+
where such license applies only to those patent claims licensable
|
|
76
|
+
by such Contributor that are necessarily infringed by their
|
|
77
|
+
Contribution(s) alone or by the combination of their Contribution(s)
|
|
78
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
79
|
+
institute patent litigation against any entity (including a cross-claim
|
|
80
|
+
or counterclaim in a lawsuit) alleging that the Work or any
|
|
81
|
+
Contribution embodied within the Work constitutes direct or
|
|
82
|
+
contributory patent infringement, then any patent licenses granted
|
|
83
|
+
to You under this License for that Work shall terminate as of the
|
|
84
|
+
date such litigation is filed.
|
|
85
|
+
|
|
86
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
87
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
88
|
+
modifications, and in Source or Object form, provided that You
|
|
89
|
+
meet the following conditions:
|
|
90
|
+
|
|
91
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
92
|
+
Works a copy of this License; and
|
|
93
|
+
|
|
94
|
+
(b) You must cause any modified files to carry prominent notices
|
|
95
|
+
stating that You changed the files; and
|
|
96
|
+
|
|
97
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
98
|
+
that You distribute, all copyright, patent, trademark, and
|
|
99
|
+
attribution notices from the Source form of the Work,
|
|
100
|
+
excluding those notices that do not pertain to any part of
|
|
101
|
+
the Derivative Works; and
|
|
102
|
+
|
|
103
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
104
|
+
distribution, You must include a readable copy of the
|
|
105
|
+
attribution notices contained within such NOTICE file, in
|
|
106
|
+
at least one of the following places: within a NOTICE text
|
|
107
|
+
file distributed as part of the Derivative Works; within
|
|
108
|
+
the Source form or documentation, if provided along with the
|
|
109
|
+
Derivative Works; or, within a display generated by the
|
|
110
|
+
Derivative Works, if and wherever such third-party notices
|
|
111
|
+
normally appear. The contents of the NOTICE file are for
|
|
112
|
+
informational purposes only and do not modify the License.
|
|
113
|
+
You may add Your own attribution notices within Derivative
|
|
114
|
+
Works that You distribute, alongside or as an addendum to
|
|
115
|
+
the NOTICE text from the Work, provided that such additional
|
|
116
|
+
attribution notices cannot be construed as modifying the License.
|
|
117
|
+
|
|
118
|
+
You may add Your own license statement for Your modifications and
|
|
119
|
+
may provide additional grant of rights to use, copy, modify, merge,
|
|
120
|
+
publish, distribute, sublicense, and/or sell copies of the
|
|
121
|
+
Derivative Works, as these terms may be expanded or clarified.
|
|
122
|
+
|
|
123
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
124
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
125
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
126
|
+
this License, without any additional terms or conditions.
|
|
127
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
128
|
+
the terms of any separate license agreement you may have executed
|
|
129
|
+
with Licensor regarding such Contributions.
|
|
130
|
+
|
|
131
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
132
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
133
|
+
except as required for reasonable and customary use in describing the
|
|
134
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
135
|
+
|
|
136
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
137
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
138
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
139
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
140
|
+
implied, including, without limitation, any warranties or conditions
|
|
141
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
142
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
143
|
+
appropriateness of using or reproducing the Work and assume any
|
|
144
|
+
risks associated with Your exercise of permissions under this License.
|
|
145
|
+
|
|
146
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
147
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
148
|
+
unless required by applicable law (such as deliberate and grossly
|
|
149
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
150
|
+
liable to You for damages, including any direct, indirect, special,
|
|
151
|
+
incidental, or exemplary damages of any character arising as a
|
|
152
|
+
result of this License or out of the use or inability to use the
|
|
153
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
154
|
+
work stoppage, computer failure or malfunction, or all other
|
|
155
|
+
commercial damages or losses), even if such Contributor has been
|
|
156
|
+
advised of the possibility of such damages.
|
|
157
|
+
|
|
158
|
+
9. Accepting Warranty or Liability. While redistributing the Work or
|
|
159
|
+
Derivative Works thereof, You may choose to offer, and charge a fee
|
|
160
|
+
for, acceptance of support, warranty, indemnity, or other liability
|
|
161
|
+
obligations and/or rights consistent with this License. However, in
|
|
162
|
+
accepting such obligations, You may offer such conditions only on
|
|
163
|
+
Your own behalf and on Your sole responsibility, not on behalf of
|
|
164
|
+
any other Contributor, and only if You agree to indemnify, defend,
|
|
165
|
+
and hold each Contributor harmless for any liability incurred by,
|
|
166
|
+
or claims asserted against, such Contributor by reason of your
|
|
167
|
+
accepting any such warranty or additional liability.
|
|
168
|
+
|
|
169
|
+
END OF TERMS AND CONDITIONS
|
|
170
|
+
|
|
171
|
+
Copyright 2026 Sallah Kokaina
|
|
172
|
+
|
|
173
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
174
|
+
you may not use this file except in compliance with the License.
|
|
175
|
+
You may obtain a copy of the License at
|
|
176
|
+
|
|
177
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
178
|
+
|
|
179
|
+
Unless required by applicable law or agreed to in writing, software
|
|
180
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
181
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
182
|
+
implied. See the License for the specific language governing
|
|
183
|
+
permissions and limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @agentspec/claude-plugin
|
|
2
|
+
|
|
3
|
+
A [Claude Code](https://code.claude.com/docs/en/claude-code) plugin that makes a
|
|
4
|
+
Claude Code session **live-observed and policy-governed by AgentSpec — with no
|
|
5
|
+
fork of Claude Code**. Your `agent.yaml` becomes the source of truth; the plugin
|
|
6
|
+
rides Claude Code's documented extension surface (hooks + MCP + statusline).
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
| Capability | Mechanism |
|
|
11
|
+
|---|---|
|
|
12
|
+
| **Live spec↔runtime gap** | A per-user bridge daemon serves `GET /agentspec/health`. The AgentSpec sidecar control-plane polls it and flips `/gap` · `/explore` · `/health/ready` to **live** mode (`source: agent-sdk`) — the exact endpoint the sidecar expects from an SDK-integrated agent. |
|
|
13
|
+
| **Policy enforcement** (opt-in) | With `AGENTSPEC_ENFORCE` set, a `PreToolUse` hook denies `Task` spawns whose `subagent_type` isn't in `spec.subagents`, denies tools not in `spec.tools`, and asks-for-approval on `destructiveHint` tools when `spec.humanInTheLoop` requires it. **Off by default** — see [Enforcement](#enforcement-opt-in). |
|
|
14
|
+
| **Live tool/health status** | `PreToolUse`/`PostToolUse`/`SubagentStop` hooks feed the bridge, which synthesizes the `tool` health category from the real event stream. |
|
|
15
|
+
| **Status line** | `agentspec-statusline` renders `● healthy · gap 3 · tools 11/12` from cached daemon data. |
|
|
16
|
+
| **Self-query** | Bundles the AgentSpec MCP server so Claude can call `agentspec_gap`/`agentspec_audit` in-session. |
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
claude (closed binary)
|
|
22
|
+
│ hooks (SessionStart / PreToolUse / PostToolUse / SubagentStop / SessionEnd)
|
|
23
|
+
▼
|
|
24
|
+
agentspec-bridge daemon (127.0.0.1, per-user, lockfile-guarded)
|
|
25
|
+
├─ :8000 GET /agentspec/health ← the sidecar polls this (the "hinge")
|
|
26
|
+
│ POST /events ← hook observation sink
|
|
27
|
+
│ POST /decide ← enforcement decision (from agent.yaml)
|
|
28
|
+
└─ :4001 control-plane → /gap /explore /health/ready (live)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The bridge sets `UPSTREAM_URL=http://127.0.0.1:8000` **before** importing the
|
|
32
|
+
sidecar control-plane (`@agentspec/sidecar/embed`), so the control-plane's
|
|
33
|
+
`probeAgent()` targets the bridge and serves live data. Claude Code is never
|
|
34
|
+
introspected — the bridge reconstructs runtime state from config + the hook
|
|
35
|
+
event stream.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 1. Derive agent.yaml from your .claude/ config (deterministic, no LLM):
|
|
41
|
+
agentspec scan --dir . --profile claude-code
|
|
42
|
+
|
|
43
|
+
# 2. Install + enable the plugin.
|
|
44
|
+
# Published: npm i -g @agentspec/claude-plugin && /plugin marketplace add agents-oss/agentspec && /plugin install agentspec
|
|
45
|
+
# Local/dev: pnpm -F @agentspec/claude-plugin build && (cd packages/claude-plugin && npm link)
|
|
46
|
+
# /plugin marketplace add /abs/path/to/agentspec && /plugin install agentspec
|
|
47
|
+
# (or commit .claude/settings.json → { "enabledPlugins": { "agentspec": true }, "statusLine": { "type": "command", "command": "agentspec-statusline" } })
|
|
48
|
+
|
|
49
|
+
# 3. Start a session. SessionStart backgrounds the bridge; the sidecar shows live state:
|
|
50
|
+
curl 127.0.0.1:4001/gap # → { "source": "agent-sdk", ... }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Distribution status:** the repo ships `.claude-plugin/marketplace.json`, so the local/dev route works today. `npm i -g @agentspec/claude-plugin` requires the first npm publish (tracked in `task.md`).
|
|
54
|
+
|
|
55
|
+
The daemon is a per-user background process (like a language server): the
|
|
56
|
+
`SessionStart` hook does an idempotent ensure-running via a lockfile under
|
|
57
|
+
`.claude/agentspec/`, it binds **loopback only**, and it is left running across
|
|
58
|
+
sessions.
|
|
59
|
+
|
|
60
|
+
## Enforcement (opt-in)
|
|
61
|
+
|
|
62
|
+
The plugin is **observe-only by default** — it records events, serves health, and
|
|
63
|
+
drives the status line, but never blocks a tool call. Turn gates on with
|
|
64
|
+
`AGENTSPEC_ENFORCE` (a comma list) in your environment or `.claude/settings.json`
|
|
65
|
+
`env`:
|
|
66
|
+
|
|
67
|
+
| `AGENTSPEC_ENFORCE` | Effect |
|
|
68
|
+
|---|---|
|
|
69
|
+
| *(unset)* | Observe only. Every tool call is allowed. |
|
|
70
|
+
| `subagents` | Deny `Task` spawns not declared in `spec.subagents`. |
|
|
71
|
+
| `tools` | Deny tools not in `spec.tools`; ask-for-approval on `destructiveHint` tools when `spec.humanInTheLoop` requires it. |
|
|
72
|
+
| `tools,subagents` | Both gates. |
|
|
73
|
+
|
|
74
|
+
Why opt-in: `scan --profile claude-code` builds `spec.tools` from
|
|
75
|
+
`.claude/settings.json` `permissions.allow`, which is Claude Code's *auto-approve*
|
|
76
|
+
list — not an exhaustive capability list. Enforcing it by default would deny
|
|
77
|
+
tools the session would normally permit. Likewise, **unspecified** `spec.subagents`
|
|
78
|
+
(the scan default when there is no `.claude/agents/`) means *unrestricted*; only an
|
|
79
|
+
**explicit** `spec.subagents: []` means "deny all delegation."
|
|
80
|
+
|
|
81
|
+
## Fidelity note
|
|
82
|
+
|
|
83
|
+
Infra checks (`env`/`model`/`mcp`/`memory`/`service`) are real reachability
|
|
84
|
+
probes from the SDK. The `tool` category is reconstructed from the hook stream:
|
|
85
|
+
a tool that ran is `pass`/`fail` by its outcome; a declared tool not yet invoked
|
|
86
|
+
this session is `skip` (no behavioral evidence yet — the same blind spot the
|
|
87
|
+
in-process Reporter has for a registered-but-uncalled tool).
|
|
88
|
+
|
|
89
|
+
## Layout
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
.claude-plugin/plugin.json plugin manifest (references hooks + mcp)
|
|
93
|
+
hooks/hooks.json + *.mjs lifecycle hooks (dependency-free, fail-open)
|
|
94
|
+
.mcp.json registers @agentspec/mcp for the session
|
|
95
|
+
src/policy.ts enforcement decision (pure, tested)
|
|
96
|
+
src/event-log.ts hook event → tool observations (pure, tested)
|
|
97
|
+
src/health-overlay.ts synthesize the `tool` category (pure, tested)
|
|
98
|
+
src/bridge.ts the daemon (bin: agentspec-bridge)
|
|
99
|
+
src/statusline.ts the status line (bin: agentspec-statusline)
|
|
100
|
+
```
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bridge.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
7
|
+
import { join, resolve } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { loadManifest, runHealthCheck } from "@agentspec/sdk";
|
|
10
|
+
|
|
11
|
+
// src/slugify.ts
|
|
12
|
+
function slugify(s) {
|
|
13
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/event-log.ts
|
|
17
|
+
function mcpServerOf(toolName) {
|
|
18
|
+
if (!toolName.startsWith("mcp__")) return void 0;
|
|
19
|
+
const parts = toolName.split("__");
|
|
20
|
+
return parts[1] || void 0;
|
|
21
|
+
}
|
|
22
|
+
var EventLog = class {
|
|
23
|
+
events = [];
|
|
24
|
+
/** Slugified tool name → best-known outcome this session. */
|
|
25
|
+
toolOutcomes = /* @__PURE__ */ new Map();
|
|
26
|
+
/** Slugified MCP server name → observed as reachable. */
|
|
27
|
+
mcpSeen = /* @__PURE__ */ new Set();
|
|
28
|
+
record(ev) {
|
|
29
|
+
this.events.push(ev);
|
|
30
|
+
if (!ev.toolName) return;
|
|
31
|
+
const key = slugify(ev.toolName);
|
|
32
|
+
if (ev.event === "PreToolUse") {
|
|
33
|
+
if (!this.toolOutcomes.has(key)) this.toolOutcomes.set(key, "pass");
|
|
34
|
+
} else if (ev.event === "PostToolUse") {
|
|
35
|
+
this.toolOutcomes.set(key, ev.ok === false ? "fail" : "pass");
|
|
36
|
+
}
|
|
37
|
+
const server = ev.mcpServer ?? mcpServerOf(ev.toolName);
|
|
38
|
+
if (server) this.mcpSeen.add(slugify(server));
|
|
39
|
+
}
|
|
40
|
+
/** Slugified-tool → 'pass'|'fail' for every tool observed this session. */
|
|
41
|
+
toolObservations() {
|
|
42
|
+
return Object.fromEntries(this.toolOutcomes);
|
|
43
|
+
}
|
|
44
|
+
/** Slugified MCP server names observed as reachable this session. */
|
|
45
|
+
observedMcpServers() {
|
|
46
|
+
return [...this.mcpSeen];
|
|
47
|
+
}
|
|
48
|
+
size() {
|
|
49
|
+
return this.events.length;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/health-overlay.ts
|
|
54
|
+
function toolCheck(name, outcome) {
|
|
55
|
+
if (outcome === "pass") {
|
|
56
|
+
return { id: `tool:${name}`, category: "tool", status: "pass", severity: "info" };
|
|
57
|
+
}
|
|
58
|
+
if (outcome === "fail") {
|
|
59
|
+
return {
|
|
60
|
+
id: `tool:${name}`,
|
|
61
|
+
category: "tool",
|
|
62
|
+
status: "fail",
|
|
63
|
+
severity: "warning",
|
|
64
|
+
message: "Tool call errored this session"
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
id: `tool:${name}`,
|
|
69
|
+
category: "tool",
|
|
70
|
+
status: "skip",
|
|
71
|
+
severity: "info",
|
|
72
|
+
message: "Declared but not yet invoked this session"
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function recount(checks) {
|
|
76
|
+
return {
|
|
77
|
+
passed: checks.filter((c) => c.status === "pass").length,
|
|
78
|
+
failed: checks.filter((c) => c.status === "fail").length,
|
|
79
|
+
warnings: checks.filter((c) => c.status === "warn").length,
|
|
80
|
+
skipped: checks.filter((c) => c.status === "skip").length
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function overallStatus(checks) {
|
|
84
|
+
const hasError = checks.some((c) => c.status === "fail" && c.severity === "error");
|
|
85
|
+
const hasWarning = checks.some((c) => c.status === "fail" && c.severity === "warning") || checks.some((c) => c.status === "warn");
|
|
86
|
+
return hasError ? "unhealthy" : hasWarning ? "degraded" : "healthy";
|
|
87
|
+
}
|
|
88
|
+
function buildLiveReport(base, manifest, observations) {
|
|
89
|
+
const toolChecks = (manifest.spec.tools ?? []).map((t) => {
|
|
90
|
+
const name = slugify(t.name);
|
|
91
|
+
return toolCheck(name, observations[name]);
|
|
92
|
+
});
|
|
93
|
+
const checks = [...base.checks, ...toolChecks];
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
checks,
|
|
97
|
+
status: overallStatus(checks),
|
|
98
|
+
summary: recount(checks)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/policy.ts
|
|
103
|
+
var ALLOW = { decision: "allow" };
|
|
104
|
+
function declaredToolNames(manifest) {
|
|
105
|
+
return (manifest.spec.tools ?? []).map((t) => slugify(t.name));
|
|
106
|
+
}
|
|
107
|
+
function requiresApprovalBeforeDestructive(manifest) {
|
|
108
|
+
return (manifest.spec.humanInTheLoop?.approvalRequired ?? []).includes("before-destructive-tool");
|
|
109
|
+
}
|
|
110
|
+
function decideTask(manifest, event) {
|
|
111
|
+
const subagents = manifest.spec.subagents;
|
|
112
|
+
if (subagents === void 0) return ALLOW;
|
|
113
|
+
const allowed = subagents.map((s) => slugify(s.name));
|
|
114
|
+
if (allowed.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
decision: "deny",
|
|
117
|
+
reason: "spec.subagents is explicitly empty \u2014 this agent declares no delegation. Task calls are not permitted."
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const requested = event.tool_input?.["subagent_type"];
|
|
121
|
+
if (typeof requested !== "string") {
|
|
122
|
+
return {
|
|
123
|
+
decision: "deny",
|
|
124
|
+
reason: `Task call has no subagent_type \u2014 cannot verify against spec.subagents (allowed: ${allowed.join(", ")}).`
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!allowed.includes(slugify(requested))) {
|
|
128
|
+
return {
|
|
129
|
+
decision: "deny",
|
|
130
|
+
reason: `Subagent "${requested}" is not declared in spec.subagents (allowed: ${allowed.join(", ")}).`
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return ALLOW;
|
|
134
|
+
}
|
|
135
|
+
function decideTool(manifest, event) {
|
|
136
|
+
const declared = declaredToolNames(manifest);
|
|
137
|
+
if (declared.length === 0) return ALLOW;
|
|
138
|
+
const name = slugify(event.tool_name);
|
|
139
|
+
if (!declared.includes(name)) {
|
|
140
|
+
return {
|
|
141
|
+
decision: "deny",
|
|
142
|
+
reason: `Tool "${event.tool_name}" is not declared in spec.tools.`
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const tool = (manifest.spec.tools ?? []).find((t) => slugify(t.name) === name);
|
|
146
|
+
if (tool?.annotations?.destructiveHint && requiresApprovalBeforeDestructive(manifest)) {
|
|
147
|
+
return {
|
|
148
|
+
decision: "ask",
|
|
149
|
+
reason: `Tool "${event.tool_name}" is marked destructive and spec.humanInTheLoop requires approval before destructive tools.`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return ALLOW;
|
|
153
|
+
}
|
|
154
|
+
function decide(manifest, event, enforce = /* @__PURE__ */ new Set()) {
|
|
155
|
+
if (event.tool_name === "Task") {
|
|
156
|
+
return enforce.has("subagents") ? decideTask(manifest, event) : ALLOW;
|
|
157
|
+
}
|
|
158
|
+
return enforce.has("tools") ? decideTool(manifest, event) : ALLOW;
|
|
159
|
+
}
|
|
160
|
+
function parseEnforce(raw) {
|
|
161
|
+
const gates = /* @__PURE__ */ new Set();
|
|
162
|
+
for (const token of (raw ?? "").split(",").map((t) => t.trim().toLowerCase())) {
|
|
163
|
+
if (token === "tools" || token === "subagents") gates.add(token);
|
|
164
|
+
}
|
|
165
|
+
return gates;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/bridge.ts
|
|
169
|
+
var BRIDGE_HOST = "127.0.0.1";
|
|
170
|
+
var BRIDGE_PORT = Number(process.env["AGENTSPEC_BRIDGE_PORT"] ?? 8e3);
|
|
171
|
+
var CONTROL_PLANE_PORT = Number(process.env["AGENTSPEC_CONTROL_PLANE_PORT"] ?? 4001);
|
|
172
|
+
var HEALTH_REFRESH_MS = 3e4;
|
|
173
|
+
function projectDir() {
|
|
174
|
+
return resolve(process.env["AGENTSPEC_PROJECT_DIR"] ?? process.cwd());
|
|
175
|
+
}
|
|
176
|
+
function stateDir() {
|
|
177
|
+
return join(projectDir(), ".claude", "agentspec");
|
|
178
|
+
}
|
|
179
|
+
function manifestPath() {
|
|
180
|
+
return process.env["AGENTSPEC_MANIFEST"] ?? join(projectDir(), "agent.yaml");
|
|
181
|
+
}
|
|
182
|
+
function pidFile() {
|
|
183
|
+
return join(stateDir(), "bridge.pid");
|
|
184
|
+
}
|
|
185
|
+
function lastReportFile() {
|
|
186
|
+
return join(stateDir(), "last-report.json");
|
|
187
|
+
}
|
|
188
|
+
function isRunning() {
|
|
189
|
+
const f = pidFile();
|
|
190
|
+
if (!existsSync(f)) return null;
|
|
191
|
+
try {
|
|
192
|
+
const pid = Number(JSON.parse(readFileSync(f, "utf-8")).pid);
|
|
193
|
+
process.kill(pid, 0);
|
|
194
|
+
return pid;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function writeLock() {
|
|
200
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
201
|
+
writeFileSync(
|
|
202
|
+
pidFile(),
|
|
203
|
+
JSON.stringify({ pid: process.pid, bridgePort: BRIDGE_PORT, controlPlanePort: CONTROL_PLANE_PORT })
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
function clearLock() {
|
|
207
|
+
try {
|
|
208
|
+
rmSync(pidFile());
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var HealthCache = class {
|
|
213
|
+
constructor(manifest, baseDir, events) {
|
|
214
|
+
this.manifest = manifest;
|
|
215
|
+
this.baseDir = baseDir;
|
|
216
|
+
this.events = events;
|
|
217
|
+
}
|
|
218
|
+
base = null;
|
|
219
|
+
start() {
|
|
220
|
+
void this.refresh();
|
|
221
|
+
const t = setInterval(() => void this.refresh(), HEALTH_REFRESH_MS);
|
|
222
|
+
if (t.unref) t.unref();
|
|
223
|
+
}
|
|
224
|
+
async refresh() {
|
|
225
|
+
try {
|
|
226
|
+
this.base = await runHealthCheck(this.manifest, { baseDir: this.baseDir });
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Live report = cached infra checks + synthesized tool checks from hooks. */
|
|
231
|
+
async live() {
|
|
232
|
+
if (!this.base) await this.refresh();
|
|
233
|
+
const base = this.base ?? {
|
|
234
|
+
agentName: this.manifest.metadata.name,
|
|
235
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
236
|
+
status: "degraded",
|
|
237
|
+
summary: { passed: 0, failed: 0, warnings: 1, skipped: 0 },
|
|
238
|
+
checks: []
|
|
239
|
+
};
|
|
240
|
+
const report = buildLiveReport(base, this.manifest, this.events.toolObservations());
|
|
241
|
+
try {
|
|
242
|
+
writeFileSync(lastReportFile(), JSON.stringify(report));
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
return report;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
function sendJson(res, code, body) {
|
|
249
|
+
const payload = JSON.stringify(body);
|
|
250
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
251
|
+
res.end(payload);
|
|
252
|
+
}
|
|
253
|
+
async function readBody(req) {
|
|
254
|
+
const chunks = [];
|
|
255
|
+
for await (const c of req) chunks.push(c);
|
|
256
|
+
if (chunks.length === 0) return {};
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
259
|
+
} catch {
|
|
260
|
+
return {};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function isToolFailure(output) {
|
|
264
|
+
if (output.isError === true) return true;
|
|
265
|
+
return typeof output.exit_code === "number" && output.exit_code !== 0;
|
|
266
|
+
}
|
|
267
|
+
function buildBridgeServer(manifest, cache, events, enforce) {
|
|
268
|
+
return createServer((req, res) => {
|
|
269
|
+
void handle(req, res).catch((err) => sendJson(res, 500, { error: String(err) }));
|
|
270
|
+
});
|
|
271
|
+
async function handle(req, res) {
|
|
272
|
+
const url = req.url ?? "/";
|
|
273
|
+
if (req.method === "GET" && url === "/agentspec/health") {
|
|
274
|
+
return sendJson(res, 200, await cache.live());
|
|
275
|
+
}
|
|
276
|
+
if (req.method === "POST" && url === "/events") {
|
|
277
|
+
const body = await readBody(req);
|
|
278
|
+
const ev = String(body.event ?? "PreToolUse");
|
|
279
|
+
events.record({
|
|
280
|
+
event: ev === "PostToolUse" || ev === "SubagentStop" ? ev : "PreToolUse",
|
|
281
|
+
toolName: body.tool_name,
|
|
282
|
+
// A result is a failure only if explicitly signalled — isError, or a
|
|
283
|
+
// present non-zero exit code. Most tools (Read/Edit/MCP) report neither
|
|
284
|
+
// an exit_code nor isError on success, so absence ⇒ success.
|
|
285
|
+
ok: body.tool_output ? !isToolFailure(body.tool_output) : void 0,
|
|
286
|
+
ts: Date.now()
|
|
287
|
+
});
|
|
288
|
+
return sendJson(res, 200, { ok: true, events: events.size() });
|
|
289
|
+
}
|
|
290
|
+
if (req.method === "POST" && url === "/decide") {
|
|
291
|
+
const body = await readBody(req);
|
|
292
|
+
return sendJson(
|
|
293
|
+
res,
|
|
294
|
+
200,
|
|
295
|
+
decide(manifest, { tool_name: body.tool_name, tool_input: body.tool_input }, enforce)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
sendJson(res, 404, { error: "not found" });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function cmdEnsure() {
|
|
302
|
+
const pid = isRunning();
|
|
303
|
+
if (pid) {
|
|
304
|
+
process.stdout.write(`agentspec-bridge already running (pid ${pid})
|
|
305
|
+
`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const self = fileURLToPath(import.meta.url);
|
|
309
|
+
const child = spawn(process.execPath, [self, "start"], {
|
|
310
|
+
detached: true,
|
|
311
|
+
stdio: "ignore",
|
|
312
|
+
env: { ...process.env, AGENTSPEC_PROJECT_DIR: projectDir() }
|
|
313
|
+
});
|
|
314
|
+
child.unref();
|
|
315
|
+
process.stdout.write(`agentspec-bridge started (pid ${child.pid})
|
|
316
|
+
`);
|
|
317
|
+
}
|
|
318
|
+
async function cmdStart() {
|
|
319
|
+
if (isRunning()) {
|
|
320
|
+
process.stderr.write("agentspec-bridge already running\n");
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
if (!existsSync(manifestPath())) {
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`agentspec-bridge: no manifest at ${manifestPath()}. Run: agentspec scan --dir . --profile claude-code
|
|
326
|
+
`
|
|
327
|
+
);
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
const { manifest } = loadManifest(manifestPath());
|
|
331
|
+
const events = new EventLog();
|
|
332
|
+
const cache = new HealthCache(manifest, projectDir(), events);
|
|
333
|
+
cache.start();
|
|
334
|
+
process.env["UPSTREAM_URL"] = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;
|
|
335
|
+
const { buildControlPlaneApp, AuditRing } = await import("@agentspec/sidecar/embed");
|
|
336
|
+
const cp = await buildControlPlaneApp(manifest, new AuditRing(), { startedAt: Date.now() });
|
|
337
|
+
const enforce = parseEnforce(process.env["AGENTSPEC_ENFORCE"]);
|
|
338
|
+
const bridge = buildBridgeServer(manifest, cache, events, enforce);
|
|
339
|
+
await new Promise((r) => bridge.listen(BRIDGE_PORT, BRIDGE_HOST, r));
|
|
340
|
+
await cp.listen({ port: CONTROL_PLANE_PORT, host: BRIDGE_HOST });
|
|
341
|
+
writeLock();
|
|
342
|
+
process.stdout.write(
|
|
343
|
+
`agentspec-bridge listening \u2014 health http://${BRIDGE_HOST}:${BRIDGE_PORT}/agentspec/health, control-plane http://${BRIDGE_HOST}:${CONTROL_PLANE_PORT}
|
|
344
|
+
`
|
|
345
|
+
);
|
|
346
|
+
const shutdown = async () => {
|
|
347
|
+
clearLock();
|
|
348
|
+
await new Promise((r) => bridge.close(() => r()));
|
|
349
|
+
await cp.close();
|
|
350
|
+
process.exit(0);
|
|
351
|
+
};
|
|
352
|
+
process.once("SIGTERM", () => void shutdown());
|
|
353
|
+
process.once("SIGINT", () => void shutdown());
|
|
354
|
+
}
|
|
355
|
+
function cmdStop() {
|
|
356
|
+
const pid = isRunning();
|
|
357
|
+
if (!pid) {
|
|
358
|
+
process.stdout.write("agentspec-bridge not running\n");
|
|
359
|
+
clearLock();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
process.kill(pid, "SIGTERM");
|
|
363
|
+
process.stdout.write(`agentspec-bridge stopped (pid ${pid})
|
|
364
|
+
`);
|
|
365
|
+
}
|
|
366
|
+
var sub = process.argv[2] ?? "ensure";
|
|
367
|
+
if (sub === "ensure") cmdEnsure();
|
|
368
|
+
else if (sub === "start") void cmdStart();
|
|
369
|
+
else if (sub === "stop") cmdStop();
|
|
370
|
+
else {
|
|
371
|
+
process.stderr.write(`unknown subcommand "${sub}" (use: ensure | start | stop)
|
|
372
|
+
`);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/bridge.ts","../src/slugify.ts","../src/event-log.ts","../src/health-overlay.ts","../src/policy.ts"],"sourcesContent":["/**\n * agentspec-bridge — the per-user local daemon (plan Part D / Part 4).\n *\n * Runs two loopback servers in one process:\n * :8000 (UPSTREAM_URL) bridge — GET /agentspec/health, POST /events, POST /decide\n * :4001 (control-plane) sidecar — GET /gap /explore /health/ready (reused as-is)\n *\n * The control-plane's probeAgent() polls http://127.0.0.1:8000/agentspec/health\n * (agent-probe.ts) and, because the bridge serves a valid HealthReport, flips\n * every route to live mode (source: 'agent-sdk'). Claude Code is never touched.\n *\n * IMPORTANT (plan C2): UPSTREAM_URL must be set BEFORE the sidecar control-plane\n * module is imported — config.ts reads it at module load. We set env, then\n * dynamic-import '@agentspec/sidecar/embed'.\n *\n * Subcommands:\n * ensure — idempotent: spawn the daemon detached if not already running\n * start — run the servers in the foreground (invoked by `ensure`)\n * stop — terminate a running daemon via its pidfile\n */\n\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http'\nimport { spawn } from 'node:child_process'\nimport { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { loadManifest, runHealthCheck, type HealthReport, type AgentSpecManifest } from '@agentspec/sdk'\nimport { EventLog } from './event-log.js'\nimport { buildLiveReport } from './health-overlay.js'\nimport { decide, parseEnforce, type EnforceGate, type ToolCallEvent } from './policy.js'\n\n// ── Config ──────────────────────────────────────────────────────────────────────\n\nconst BRIDGE_HOST = '127.0.0.1'\nconst BRIDGE_PORT = Number(process.env['AGENTSPEC_BRIDGE_PORT'] ?? 8000)\nconst CONTROL_PLANE_PORT = Number(process.env['AGENTSPEC_CONTROL_PLANE_PORT'] ?? 4001)\nconst HEALTH_REFRESH_MS = 30_000\n\nfunction projectDir(): string {\n return resolve(process.env['AGENTSPEC_PROJECT_DIR'] ?? process.cwd())\n}\nfunction stateDir(): string {\n return join(projectDir(), '.claude', 'agentspec')\n}\nfunction manifestPath(): string {\n return process.env['AGENTSPEC_MANIFEST'] ?? join(projectDir(), 'agent.yaml')\n}\nfunction pidFile(): string {\n return join(stateDir(), 'bridge.pid')\n}\nfunction lastReportFile(): string {\n return join(stateDir(), 'last-report.json')\n}\n\n// ── Lockfile ──────────────────────────────────────────────────────────────────\n\nfunction isRunning(): number | null {\n const f = pidFile()\n if (!existsSync(f)) return null\n try {\n const pid = Number(JSON.parse(readFileSync(f, 'utf-8')).pid)\n process.kill(pid, 0) // throws if the process is gone\n return pid\n } catch {\n return null\n }\n}\n\nfunction writeLock(): void {\n mkdirSync(stateDir(), { recursive: true })\n writeFileSync(\n pidFile(),\n JSON.stringify({ pid: process.pid, bridgePort: BRIDGE_PORT, controlPlanePort: CONTROL_PLANE_PORT }),\n )\n}\n\nfunction clearLock(): void {\n try {\n rmSync(pidFile())\n } catch {\n /* already gone */\n }\n}\n\n// ── Health cache (mirrors the Reporter's background-refresh pattern) ─────────────\n\nclass HealthCache {\n private base: HealthReport | null = null\n\n constructor(\n private readonly manifest: AgentSpecManifest,\n private readonly baseDir: string,\n private readonly events: EventLog,\n ) {}\n\n start(): void {\n void this.refresh()\n const t = setInterval(() => void this.refresh(), HEALTH_REFRESH_MS)\n if (t.unref) t.unref()\n }\n\n private async refresh(): Promise<void> {\n try {\n this.base = await runHealthCheck(this.manifest, { baseDir: this.baseDir })\n } catch {\n /* keep the previous base on error */\n }\n }\n\n /** Live report = cached infra checks + synthesized tool checks from hooks. */\n async live(): Promise<HealthReport> {\n if (!this.base) await this.refresh()\n const base = this.base ?? {\n agentName: this.manifest.metadata.name,\n timestamp: new Date().toISOString(),\n status: 'degraded' as const,\n summary: { passed: 0, failed: 0, warnings: 1, skipped: 0 },\n checks: [],\n }\n const report = buildLiveReport(base, this.manifest, this.events.toolObservations())\n try {\n writeFileSync(lastReportFile(), JSON.stringify(report))\n } catch {\n /* best-effort persistence for the statusline */\n }\n return report\n }\n}\n\n// ── HTTP helpers ────────────────────────────────────────────────────────────────\n\nfunction sendJson(res: ServerResponse, code: number, body: unknown): void {\n const payload = JSON.stringify(body)\n res.writeHead(code, { 'Content-Type': 'application/json' })\n res.end(payload)\n}\n\nasync function readBody(req: IncomingMessage): Promise<unknown> {\n const chunks: Buffer[] = []\n for await (const c of req) chunks.push(c as Buffer)\n if (chunks.length === 0) return {}\n try {\n return JSON.parse(Buffer.concat(chunks).toString('utf-8'))\n } catch {\n return {}\n }\n}\n\n/** A PostToolUse result is a failure only if explicitly signalled. */\nfunction isToolFailure(output: { exit_code?: number; isError?: boolean }): boolean {\n if (output.isError === true) return true\n return typeof output.exit_code === 'number' && output.exit_code !== 0\n}\n\n// ── Bridge server ────────────────────────────────────────────────────────────────\n\nfunction buildBridgeServer(\n manifest: AgentSpecManifest,\n cache: HealthCache,\n events: EventLog,\n enforce: ReadonlySet<EnforceGate>,\n): ReturnType<typeof createServer> {\n return createServer((req, res) => {\n void handle(req, res).catch((err) => sendJson(res, 500, { error: String(err) }))\n })\n\n async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const url = req.url ?? '/'\n\n // The hinge the sidecar polls.\n if (req.method === 'GET' && url === '/agentspec/health') {\n return sendJson(res, 200, await cache.live())\n }\n\n // Hook event sink (observation).\n if (req.method === 'POST' && url === '/events') {\n const body = (await readBody(req)) as {\n event?: string\n tool_name?: string\n tool_output?: { exit_code?: number; isError?: boolean }\n }\n const ev = String(body.event ?? 'PreToolUse')\n events.record({\n event: ev === 'PostToolUse' || ev === 'SubagentStop' ? ev : 'PreToolUse',\n toolName: body.tool_name,\n // A result is a failure only if explicitly signalled — isError, or a\n // present non-zero exit code. Most tools (Read/Edit/MCP) report neither\n // an exit_code nor isError on success, so absence ⇒ success.\n ok: body.tool_output ? !isToolFailure(body.tool_output) : undefined,\n ts: Date.now(),\n })\n return sendJson(res, 200, { ok: true, events: events.size() })\n }\n\n // Enforcement decision (the PreToolUse hook calls this and echoes the verdict).\n if (req.method === 'POST' && url === '/decide') {\n const body = (await readBody(req)) as ToolCallEvent\n return sendJson(\n res,\n 200,\n decide(manifest, { tool_name: body.tool_name, tool_input: body.tool_input }, enforce),\n )\n }\n\n sendJson(res, 404, { error: 'not found' })\n }\n}\n\n// ── Subcommands ──────────────────────────────────────────────────────────────────\n\nfunction cmdEnsure(): void {\n const pid = isRunning()\n if (pid) {\n process.stdout.write(`agentspec-bridge already running (pid ${pid})\\n`)\n return\n }\n const self = fileURLToPath(import.meta.url)\n const child = spawn(process.execPath, [self, 'start'], {\n detached: true,\n stdio: 'ignore',\n env: { ...process.env, AGENTSPEC_PROJECT_DIR: projectDir() },\n })\n child.unref()\n process.stdout.write(`agentspec-bridge started (pid ${child.pid})\\n`)\n}\n\nasync function cmdStart(): Promise<void> {\n if (isRunning()) {\n process.stderr.write('agentspec-bridge already running\\n')\n process.exit(0)\n }\n if (!existsSync(manifestPath())) {\n process.stderr.write(\n `agentspec-bridge: no manifest at ${manifestPath()}. ` +\n `Run: agentspec scan --dir . --profile claude-code\\n`,\n )\n process.exit(1)\n }\n\n const { manifest } = loadManifest(manifestPath())\n const events = new EventLog()\n const cache = new HealthCache(manifest, projectDir(), events)\n cache.start()\n\n // Point the control-plane at the bridge BEFORE importing it (C2).\n process.env['UPSTREAM_URL'] = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`\n const { buildControlPlaneApp, AuditRing } = await import('@agentspec/sidecar/embed')\n const cp = await buildControlPlaneApp(manifest, new AuditRing(), { startedAt: Date.now() })\n\n // Enforcement is opt-in via AGENTSPEC_ENFORCE (e.g. \"tools,subagents\").\n // Unset ⇒ observe-only: /decide always allows.\n const enforce = parseEnforce(process.env['AGENTSPEC_ENFORCE'])\n const bridge = buildBridgeServer(manifest, cache, events, enforce)\n await new Promise<void>((r) => bridge.listen(BRIDGE_PORT, BRIDGE_HOST, r))\n await cp.listen({ port: CONTROL_PLANE_PORT, host: BRIDGE_HOST })\n\n writeLock()\n process.stdout.write(\n `agentspec-bridge listening — health http://${BRIDGE_HOST}:${BRIDGE_PORT}/agentspec/health, ` +\n `control-plane http://${BRIDGE_HOST}:${CONTROL_PLANE_PORT}\\n`,\n )\n\n const shutdown = async (): Promise<void> => {\n clearLock()\n await new Promise<void>((r) => bridge.close(() => r()))\n await cp.close()\n process.exit(0)\n }\n process.once('SIGTERM', () => void shutdown())\n process.once('SIGINT', () => void shutdown())\n}\n\nfunction cmdStop(): void {\n const pid = isRunning()\n if (!pid) {\n process.stdout.write('agentspec-bridge not running\\n')\n clearLock()\n return\n }\n process.kill(pid, 'SIGTERM')\n process.stdout.write(`agentspec-bridge stopped (pid ${pid})\\n`)\n}\n\n// ── Entry ─────────────────────────────────────────────────────────────────────\n\nconst sub = process.argv[2] ?? 'ensure'\nif (sub === 'ensure') cmdEnsure()\nelse if (sub === 'start') void cmdStart()\nelse if (sub === 'stop') cmdStop()\nelse {\n process.stderr.write(`unknown subcommand \"${sub}\" (use: ensure | start | stop)\\n`)\n process.exit(1)\n}\n","/**\n * Lowercase slug: `a-z`, `0-9`, hyphens only. Mirrors the CLI scan builder's\n * slugify (`packages/cli/src/commands/scan-builder.ts:257`) so tool/subagent\n * names produced by `scan --profile claude-code` match what this plugin's\n * policy gate compares against. Kept local to avoid a plugin→cli dependency.\n *\n * \"Read\" → \"read\"\n * \"mcp__github__create_issue\" → \"mcp-github-create-issue\"\n * \"code-reviewer\" → \"code-reviewer\"\n */\nexport function slugify(s: string): string {\n return s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n","/**\n * In-memory record of the live Claude Code hook event stream.\n *\n * Hooks are the behavioral ground truth (plan §3.1): every real tool call fires\n * PreToolUse (tool_name + tool_input) and PostToolUse (tool_output). This log\n * accumulates those events and derives, per declared tool, whether it has\n * actually run this session and whether it succeeded — the input the health\n * overlay turns into `tool`-category checks.\n *\n * Pure data structure — no I/O — unit-tested directly.\n */\n\nimport { slugify } from './slugify.js'\n\nexport type ToolOutcome = 'pass' | 'fail'\n\nexport interface RecordedEvent {\n event: 'PreToolUse' | 'PostToolUse' | 'SubagentStop'\n toolName?: string\n /** PostToolUse: whether the call succeeded (derived from tool_output). */\n ok?: boolean\n /** MCP server names observed via mcp__server__* tool calls. */\n mcpServer?: string\n ts: number\n}\n\n/** Extract the MCP server name from an `mcp__server__tool` tool name, if any. */\nfunction mcpServerOf(toolName: string): string | undefined {\n if (!toolName.startsWith('mcp__')) return undefined\n const parts = toolName.split('__')\n return parts[1] || undefined\n}\n\nexport class EventLog {\n private readonly events: RecordedEvent[] = []\n /** Slugified tool name → best-known outcome this session. */\n private readonly toolOutcomes = new Map<string, ToolOutcome>()\n /** Slugified MCP server name → observed as reachable. */\n private readonly mcpSeen = new Set<string>()\n\n record(ev: RecordedEvent): void {\n this.events.push(ev)\n if (!ev.toolName) return\n\n const key = slugify(ev.toolName)\n if (ev.event === 'PreToolUse') {\n // Seen firing, outcome not yet known — only downgrade if nothing recorded.\n if (!this.toolOutcomes.has(key)) this.toolOutcomes.set(key, 'pass')\n } else if (ev.event === 'PostToolUse') {\n this.toolOutcomes.set(key, ev.ok === false ? 'fail' : 'pass')\n }\n\n const server = ev.mcpServer ?? mcpServerOf(ev.toolName)\n if (server) this.mcpSeen.add(slugify(server))\n }\n\n /** Slugified-tool → 'pass'|'fail' for every tool observed this session. */\n toolObservations(): Record<string, ToolOutcome> {\n return Object.fromEntries(this.toolOutcomes)\n }\n\n /** Slugified MCP server names observed as reachable this session. */\n observedMcpServers(): string[] {\n return [...this.mcpSeen]\n }\n\n size(): number {\n return this.events.length\n }\n}\n","/**\n * Overlay live hook observations onto a base HealthReport to synthesize the\n * `tool` category the generic runHealthCheck() does not emit (plan C4).\n *\n * runHealthCheck (sdk/src/health/index.ts) covers env/model/mcp/memory/service/\n * subagent/eval. The agent-side Reporter is the only thing that normally adds\n * `tool` checks (reporter.ts:247). Here the bridge reconstructs them from the\n * declared tool list plus what the hook stream actually observed firing:\n * - observed 'pass' → tool ran and succeeded → pass\n * - observed 'fail' → tool ran and errored → fail\n * - not observed → declared but not invoked this run → skip (honest: no\n * behavioral evidence yet — same blind spot the real\n * Reporter has for a registered-but-uncalled tool)\n *\n * Pure function — unit-tested directly.\n */\n\nimport type { HealthReport, HealthCheck } from '@agentspec/sdk'\nimport type { AgentSpecManifest } from '@agentspec/sdk'\nimport type { ToolOutcome } from './event-log.js'\nimport { slugify } from './slugify.js'\n\nfunction toolCheck(name: string, outcome: ToolOutcome | undefined): HealthCheck {\n if (outcome === 'pass') {\n return { id: `tool:${name}`, category: 'tool', status: 'pass', severity: 'info' }\n }\n if (outcome === 'fail') {\n return {\n id: `tool:${name}`,\n category: 'tool',\n status: 'fail',\n severity: 'warning',\n message: 'Tool call errored this session',\n }\n }\n return {\n id: `tool:${name}`,\n category: 'tool',\n status: 'skip',\n severity: 'info',\n message: 'Declared but not yet invoked this session',\n }\n}\n\nfunction recount(checks: HealthCheck[]): HealthReport['summary'] {\n return {\n passed: checks.filter((c) => c.status === 'pass').length,\n failed: checks.filter((c) => c.status === 'fail').length,\n warnings: checks.filter((c) => c.status === 'warn').length,\n skipped: checks.filter((c) => c.status === 'skip').length,\n }\n}\n\nfunction overallStatus(checks: HealthCheck[]): HealthReport['status'] {\n const hasError = checks.some((c) => c.status === 'fail' && c.severity === 'error')\n const hasWarning =\n checks.some((c) => c.status === 'fail' && c.severity === 'warning') ||\n checks.some((c) => c.status === 'warn')\n return hasError ? 'unhealthy' : hasWarning ? 'degraded' : 'healthy'\n}\n\n/**\n * Return a new HealthReport with `tool`-category checks appended and the summary\n * / status recomputed over the combined set. The base report is not mutated.\n */\nexport function buildLiveReport(\n base: HealthReport,\n manifest: AgentSpecManifest,\n observations: Record<string, ToolOutcome>,\n): HealthReport {\n const toolChecks = (manifest.spec.tools ?? []).map((t) => {\n const name = slugify(t.name)\n return toolCheck(name, observations[name])\n })\n\n const checks = [...base.checks, ...toolChecks]\n return {\n ...base,\n checks,\n status: overallStatus(checks),\n summary: recount(checks),\n }\n}\n","/**\n * Enforcement policy: turn the declarative agent.yaml into an allow/deny/ask\n * decision for a Claude Code tool call.\n *\n * The manifest is purely declarative (no permission block in the schema) — this\n * module is the enforcement layer the plan describes. Enforcement is OPT-IN: the\n * caller passes the set of gates it wants active (from AGENTSPEC_ENFORCE). With\n * no gate enabled — the default — every decision is `allow` and the plugin only\n * observes. This keeps the out-of-box `scan → enable` path from bricking a\n * normal session (review findings H1/H2).\n *\n * - Subagent gate (opt-in `subagents`): a `Task` spawn is denied unless its\n * subagent_type is in spec.subagents. `spec.subagents` UNSPECIFIED (what\n * `scan` produces when there is no .claude/agents/) ⇒ unrestricted; an\n * EXPLICIT `spec.subagents: []` ⇒ deny all delegation (a deliberate choice).\n * - Tool gate (opt-in `tools`): a tool is denied unless it is in spec.tools.\n * Empty/absent spec.tools ⇒ no allow-list declared ⇒ unrestricted (allow).\n * - Destructive gate (within the tool gate): a declared tool with\n * annotations.destructiveHint asks for approval when\n * humanInTheLoop.approvalRequired includes \"before-destructive-tool\"\n * (schema: manifest.schema.ts:523).\n *\n * Pure function — no I/O — so it is unit-tested directly (see __tests__).\n */\n\nimport type { AgentSpecManifest } from '@agentspec/sdk'\nimport { slugify } from './slugify.js'\n\nexport type PermissionDecision = 'allow' | 'deny' | 'ask'\n\n/** Gates that enforcement can activate (parsed from AGENTSPEC_ENFORCE). */\nexport type EnforceGate = 'tools' | 'subagents'\n\nexport interface PolicyDecision {\n decision: PermissionDecision\n /** Present for deny/ask — surfaced to the model as the reason. */\n reason?: string\n}\n\nexport interface ToolCallEvent {\n tool_name: string\n tool_input?: Record<string, unknown>\n}\n\nconst ALLOW: PolicyDecision = { decision: 'allow' }\n\n// ── Private helpers ────────────────────────────────────────────────────────────\n\nfunction declaredToolNames(manifest: AgentSpecManifest): string[] {\n return (manifest.spec.tools ?? []).map((t) => slugify(t.name))\n}\n\nfunction requiresApprovalBeforeDestructive(manifest: AgentSpecManifest): boolean {\n return (manifest.spec.humanInTheLoop?.approvalRequired ?? []).includes('before-destructive-tool')\n}\n\nfunction decideTask(manifest: AgentSpecManifest, event: ToolCallEvent): PolicyDecision {\n const subagents = manifest.spec.subagents\n // Unspecified (no spec.subagents key) ⇒ not declaring a delegation policy ⇒\n // unrestricted. Only an explicit empty array is a deliberate \"deny all\".\n if (subagents === undefined) return ALLOW\n\n const allowed = subagents.map((s) => slugify(s.name))\n if (allowed.length === 0) {\n return {\n decision: 'deny',\n reason: 'spec.subagents is explicitly empty — this agent declares no delegation. Task calls are not permitted.',\n }\n }\n const requested = event.tool_input?.['subagent_type']\n // Missing or non-string subagent_type can't be matched against the allow-list,\n // so it must not pass the gate (otherwise it silently bypasses enforcement).\n if (typeof requested !== 'string') {\n return {\n decision: 'deny',\n reason: `Task call has no subagent_type — cannot verify against spec.subagents (allowed: ${allowed.join(', ')}).`,\n }\n }\n if (!allowed.includes(slugify(requested))) {\n return {\n decision: 'deny',\n reason: `Subagent \"${requested}\" is not declared in spec.subagents (allowed: ${allowed.join(', ')}).`,\n }\n }\n return ALLOW\n}\n\nfunction decideTool(manifest: AgentSpecManifest, event: ToolCallEvent): PolicyDecision {\n const declared = declaredToolNames(manifest)\n // No allow-list declared → do not restrict.\n if (declared.length === 0) return ALLOW\n\n const name = slugify(event.tool_name)\n if (!declared.includes(name)) {\n return {\n decision: 'deny',\n reason: `Tool \"${event.tool_name}\" is not declared in spec.tools.`,\n }\n }\n\n const tool = (manifest.spec.tools ?? []).find((t) => slugify(t.name) === name)\n if (tool?.annotations?.destructiveHint && requiresApprovalBeforeDestructive(manifest)) {\n return {\n decision: 'ask',\n reason: `Tool \"${event.tool_name}\" is marked destructive and spec.humanInTheLoop requires approval before destructive tools.`,\n }\n }\n return ALLOW\n}\n\n// ── Public orchestrator ────────────────────────────────────────────────────────\n\n/**\n * Decide whether a Claude Code tool call is permitted by the manifest.\n * `Task` (subagent spawn) is routed to the subagent gate; everything else to\n * the tool gate. Each gate runs only when the caller opted into it via\n * `enforce`; a disabled gate always allows (observe-only). Default: no gate.\n */\nexport function decide(\n manifest: AgentSpecManifest,\n event: ToolCallEvent,\n enforce: ReadonlySet<EnforceGate> = new Set(),\n): PolicyDecision {\n if (event.tool_name === 'Task') {\n return enforce.has('subagents') ? decideTask(manifest, event) : ALLOW\n }\n return enforce.has('tools') ? decideTool(manifest, event) : ALLOW\n}\n\n/** Parse an AGENTSPEC_ENFORCE value (\"tools,subagents\") into a gate set. */\nexport function parseEnforce(raw: string | undefined): Set<EnforceGate> {\n const gates = new Set<EnforceGate>()\n for (const token of (raw ?? '').split(',').map((t) => t.trim().toLowerCase())) {\n if (token === 'tools' || token === 'subagents') gates.add(token)\n }\n return gates\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/statusline.ts
|
|
4
|
+
var CONTROL_PLANE = `http://127.0.0.1:${process.env["AGENTSPEC_CONTROL_PLANE_PORT"] ?? 4001}`;
|
|
5
|
+
var BRIDGE = `http://127.0.0.1:${process.env["AGENTSPEC_BRIDGE_PORT"] ?? 8e3}`;
|
|
6
|
+
var FETCH_TIMEOUT_MS = 1200;
|
|
7
|
+
async function readStdin() {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
10
|
+
if (chunks.length === 0) return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function getJson(url) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
20
|
+
if (!res.ok) return null;
|
|
21
|
+
return await res.json();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function healthDot(status) {
|
|
27
|
+
if (status === "healthy") return "\u25CF healthy";
|
|
28
|
+
if (status === "degraded") return "\u25D0 degraded";
|
|
29
|
+
if (status === "unhealthy") return "\u25CB unhealthy";
|
|
30
|
+
return "\u25CB offline";
|
|
31
|
+
}
|
|
32
|
+
function toolSegment(report) {
|
|
33
|
+
if (!report) return null;
|
|
34
|
+
const toolChecks = report.checks.filter((c) => c.category === "tool");
|
|
35
|
+
if (toolChecks.length === 0) return null;
|
|
36
|
+
const live = toolChecks.filter((c) => c.status === "pass").length;
|
|
37
|
+
return `tools ${live}/${toolChecks.length}`;
|
|
38
|
+
}
|
|
39
|
+
async function main() {
|
|
40
|
+
const session = await readStdin();
|
|
41
|
+
const [report, gap] = await Promise.all([
|
|
42
|
+
getJson(`${BRIDGE}/agentspec/health`),
|
|
43
|
+
getJson(`${CONTROL_PLANE}/gap`)
|
|
44
|
+
]);
|
|
45
|
+
const segments = [healthDot(report?.status)];
|
|
46
|
+
if (gap?.issues) segments.push(`gap ${gap.issues.length}`);
|
|
47
|
+
const tools = toolSegment(report);
|
|
48
|
+
if (tools) segments.push(tools);
|
|
49
|
+
if (session.model?.display_name) segments.push(session.model.display_name);
|
|
50
|
+
if (typeof session.cost?.total_cost_usd === "number") {
|
|
51
|
+
segments.push(`$${session.cost.total_cost_usd.toFixed(3)}`);
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(`AgentSpec ${segments.join(" \xB7 ")}`);
|
|
54
|
+
}
|
|
55
|
+
void main();
|
|
56
|
+
//# sourceMappingURL=statusline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/statusline.ts"],"sourcesContent":["/**\n * agentspec-statusline — the Claude Code statusLine command (plan Part 5).\n *\n * Claude Code invokes this on each refresh with the session JSON on stdin and\n * renders stdout verbatim as the bottom status bar. MUST be fast: it only READS\n * already-computed data from the local daemon (control-plane :4001), never\n * recomputes health or audit itself.\n *\n * Output example:\n * ● healthy · gap 3 · tools 11/12\n */\n\nimport type { HealthReport } from '@agentspec/sdk'\n\nconst CONTROL_PLANE = `http://127.0.0.1:${process.env['AGENTSPEC_CONTROL_PLANE_PORT'] ?? 4001}`\n// The bridge's own health endpoint is always HTTP 200 (the control-plane's\n// /health/ready returns 503 when unhealthy, which we don't want to drop).\nconst BRIDGE = `http://127.0.0.1:${process.env['AGENTSPEC_BRIDGE_PORT'] ?? 8000}`\nconst FETCH_TIMEOUT_MS = 1200\n\ninterface SessionInput {\n model?: { display_name?: string }\n cost?: { total_cost_usd?: number }\n}\n\nasync function readStdin(): Promise<SessionInput> {\n const chunks: Buffer[] = []\n for await (const c of process.stdin) chunks.push(c as Buffer)\n if (chunks.length === 0) return {}\n try {\n return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SessionInput\n } catch {\n return {}\n }\n}\n\nasync function getJson<T>(url: string): Promise<T | null> {\n try {\n const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })\n if (!res.ok) return null\n return (await res.json()) as T\n } catch {\n return null\n }\n}\n\nfunction healthDot(status: string | undefined): string {\n if (status === 'healthy') return '● healthy'\n if (status === 'degraded') return '◐ degraded'\n if (status === 'unhealthy') return '○ unhealthy'\n return '○ offline'\n}\n\nfunction toolSegment(report: HealthReport | null): string | null {\n if (!report) return null\n const toolChecks = report.checks.filter((c) => c.category === 'tool')\n if (toolChecks.length === 0) return null\n const live = toolChecks.filter((c) => c.status === 'pass').length\n return `tools ${live}/${toolChecks.length}`\n}\n\nasync function main(): Promise<void> {\n const session = await readStdin()\n const [report, gap] = await Promise.all([\n getJson<HealthReport>(`${BRIDGE}/agentspec/health`),\n getJson<{ issues?: unknown[] }>(`${CONTROL_PLANE}/gap`),\n ])\n\n const segments: string[] = [healthDot(report?.status)]\n\n if (gap?.issues) segments.push(`gap ${gap.issues.length}`)\n const tools = toolSegment(report)\n if (tools) segments.push(tools)\n if (session.model?.display_name) segments.push(session.model.display_name)\n if (typeof session.cost?.total_cost_usd === 'number') {\n segments.push(`$${session.cost.total_cost_usd.toFixed(3)}`)\n }\n\n process.stdout.write(`AgentSpec ${segments.join(' · ')}`)\n}\n\nvoid main()\n"],"mappings":";;;AAcA,IAAM,gBAAgB,oBAAoB,QAAQ,IAAI,8BAA8B,KAAK,IAAI;AAG7F,IAAM,SAAS,oBAAoB,QAAQ,IAAI,uBAAuB,KAAK,GAAI;AAC/E,IAAM,mBAAmB;AAOzB,eAAe,YAAmC;AAChD,QAAM,SAAmB,CAAC;AAC1B,mBAAiB,KAAK,QAAQ,MAAO,QAAO,KAAK,CAAW;AAC5D,MAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AACjC,MAAI;AACF,WAAO,KAAK,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,CAAC;AAAA,EAC3D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,eAAe,QAAW,KAAgC;AACxD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ,YAAY,QAAQ,gBAAgB,EAAE,CAAC;AAC9E,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,UAAU,QAAoC;AACrD,MAAI,WAAW,UAAW,QAAO;AACjC,MAAI,WAAW,WAAY,QAAO;AAClC,MAAI,WAAW,YAAa,QAAO;AACnC,SAAO;AACT;AAEA,SAAS,YAAY,QAA4C;AAC/D,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,aAAa,OAAO,OAAO,OAAO,CAAC,MAAM,EAAE,aAAa,MAAM;AACpE,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,QAAM,OAAO,WAAW,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAC3D,SAAO,SAAS,IAAI,IAAI,WAAW,MAAM;AAC3C;AAEA,eAAe,OAAsB;AACnC,QAAM,UAAU,MAAM,UAAU;AAChC,QAAM,CAAC,QAAQ,GAAG,IAAI,MAAM,QAAQ,IAAI;AAAA,IACtC,QAAsB,GAAG,MAAM,mBAAmB;AAAA,IAClD,QAAgC,GAAG,aAAa,MAAM;AAAA,EACxD,CAAC;AAED,QAAM,WAAqB,CAAC,UAAU,QAAQ,MAAM,CAAC;AAErD,MAAI,KAAK,OAAQ,UAAS,KAAK,OAAO,IAAI,OAAO,MAAM,EAAE;AACzD,QAAM,QAAQ,YAAY,MAAM;AAChC,MAAI,MAAO,UAAS,KAAK,KAAK;AAC9B,MAAI,QAAQ,OAAO,aAAc,UAAS,KAAK,QAAQ,MAAM,YAAY;AACzE,MAAI,OAAO,QAAQ,MAAM,mBAAmB,UAAU;AACpD,aAAS,KAAK,IAAI,QAAQ,KAAK,eAAe,QAAQ,CAAC,CAAC,EAAE;AAAA,EAC5D;AAEA,UAAQ,OAAO,MAAM,aAAa,SAAS,KAAK,QAAK,CAAC,EAAE;AAC1D;AAEA,KAAK,KAAK;","names":[]}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "startup|resume",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"",
|
|
10
|
+
"timeout": 30
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"PreToolUse": [
|
|
16
|
+
{
|
|
17
|
+
"matcher": "*",
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs\"",
|
|
22
|
+
"timeout": 10
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"PostToolUse": [
|
|
28
|
+
{
|
|
29
|
+
"matcher": "*",
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.mjs\"",
|
|
34
|
+
"timeout": 10
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"SubagentStop": [
|
|
40
|
+
{
|
|
41
|
+
"matcher": "",
|
|
42
|
+
"hooks": [
|
|
43
|
+
{
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.mjs\"",
|
|
46
|
+
"timeout": 10
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"SessionEnd": [
|
|
52
|
+
{
|
|
53
|
+
"matcher": "",
|
|
54
|
+
"hooks": [
|
|
55
|
+
{
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end.mjs\"",
|
|
58
|
+
"timeout": 10
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse hook — behavioral observation. Reports the tool's outcome to the
|
|
4
|
+
* bridge so it can mark the `tool` (and mcp) health checks live. Fails silent.
|
|
5
|
+
*/
|
|
6
|
+
const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
|
|
7
|
+
const BASE = `http://127.0.0.1:${PORT}`
|
|
8
|
+
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
const chunks = []
|
|
11
|
+
for await (const c of process.stdin) chunks.push(c)
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
|
|
14
|
+
} catch {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const input = await readStdin()
|
|
21
|
+
try {
|
|
22
|
+
await fetch(`${BASE}/events`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
event: 'PostToolUse',
|
|
27
|
+
tool_name: input.tool_name,
|
|
28
|
+
tool_output: input.tool_output,
|
|
29
|
+
}),
|
|
30
|
+
signal: AbortSignal.timeout(1500),
|
|
31
|
+
})
|
|
32
|
+
} catch {
|
|
33
|
+
/* fail silent */
|
|
34
|
+
}
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse hook — enforcement + observation.
|
|
4
|
+
*
|
|
5
|
+
* Posts the tool call to the bridge for observation, then asks the bridge to
|
|
6
|
+
* decide against agent.yaml (subagent gate, tool allow-list, destructive gate).
|
|
7
|
+
* Emits Claude Code's permission JSON for deny/ask; stays silent (normal flow)
|
|
8
|
+
* for allow. Fails OPEN if the bridge is unreachable — never blocks on error.
|
|
9
|
+
*/
|
|
10
|
+
const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
|
|
11
|
+
const BASE = `http://127.0.0.1:${PORT}`
|
|
12
|
+
const TIMEOUT = 1500
|
|
13
|
+
|
|
14
|
+
async function post(path, body) {
|
|
15
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
20
|
+
})
|
|
21
|
+
return res.ok ? res.json() : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readStdin() {
|
|
25
|
+
const chunks = []
|
|
26
|
+
for await (const c of process.stdin) chunks.push(c)
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const input = await readStdin()
|
|
36
|
+
const toolName = input.tool_name
|
|
37
|
+
const toolInput = input.tool_input ?? {}
|
|
38
|
+
|
|
39
|
+
// Observation (best-effort).
|
|
40
|
+
post('/events', { event: 'PreToolUse', tool_name: toolName }).catch(() => {})
|
|
41
|
+
|
|
42
|
+
// Enforcement.
|
|
43
|
+
let verdict = null
|
|
44
|
+
try {
|
|
45
|
+
verdict = await post('/decide', { tool_name: toolName, tool_input: toolInput })
|
|
46
|
+
} catch {
|
|
47
|
+
verdict = null // fail open
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (verdict && (verdict.decision === 'deny' || verdict.decision === 'ask')) {
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
hookSpecificOutput: {
|
|
54
|
+
hookEventName: 'PreToolUse',
|
|
55
|
+
permissionDecision: verdict.decision,
|
|
56
|
+
permissionDecisionReason: verdict.reason ?? 'Blocked by AgentSpec manifest policy',
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
process.exit(0)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionEnd hook — flush point. By default the per-user daemon is left running
|
|
4
|
+
* (it behaves like a language server across sessions). Stretch: when
|
|
5
|
+
* AGENTSPEC_URL + AGENTSPEC_KEY are set, the bridge's push mode reports a final
|
|
6
|
+
* heartbeat to the operator; that wiring lives in the daemon, so this hook is a
|
|
7
|
+
* no-op placeholder that always exits 0.
|
|
8
|
+
*/
|
|
9
|
+
async function main() {
|
|
10
|
+
for await (const _ of process.stdin) { /* drain */ }
|
|
11
|
+
process.exit(0)
|
|
12
|
+
}
|
|
13
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart hook — idempotently ensure the agentspec-bridge daemon is up.
|
|
4
|
+
*
|
|
5
|
+
* Backgrounds the daemon (lockfile-guarded inside `agentspec-bridge ensure`) and
|
|
6
|
+
* returns immediately. If no agent.yaml exists yet, advises the user to scan
|
|
7
|
+
* rather than starting a daemon with nothing to serve. Always exits 0 — a
|
|
8
|
+
* governance plugin must never block a session from starting.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
// Drain stdin (SessionStart payload) but we don't need it.
|
|
16
|
+
for await (const _ of process.stdin) { /* ignore */ }
|
|
17
|
+
|
|
18
|
+
const cwd = process.cwd()
|
|
19
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? join(cwd, 'node_modules', '@agentspec', 'claude-plugin')
|
|
20
|
+
|
|
21
|
+
if (!existsSync(join(cwd, 'agent.yaml'))) {
|
|
22
|
+
process.stderr.write(
|
|
23
|
+
'[agentspec] No agent.yaml found — run `agentspec scan --dir . --profile claude-code` to enable live governance.\n',
|
|
24
|
+
)
|
|
25
|
+
process.exit(0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const child = spawn(process.execPath, [join(pluginRoot, 'dist', 'bridge.js'), 'ensure'], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
env: { ...process.env, AGENTSPEC_PROJECT_DIR: cwd },
|
|
32
|
+
})
|
|
33
|
+
child.unref()
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SubagentStop hook — records subagent completion so the bridge can reflect the
|
|
4
|
+
* `subagent` category as live. Fails silent.
|
|
5
|
+
*/
|
|
6
|
+
const PORT = process.env.AGENTSPEC_BRIDGE_PORT ?? 8000
|
|
7
|
+
|
|
8
|
+
async function readStdin() {
|
|
9
|
+
const chunks = []
|
|
10
|
+
for await (const c of process.stdin) chunks.push(c)
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf-8'))
|
|
13
|
+
} catch {
|
|
14
|
+
return {}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const input = await readStdin()
|
|
20
|
+
try {
|
|
21
|
+
await fetch(`http://127.0.0.1:${PORT}/events`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ event: 'SubagentStop', tool_name: input.tool_name }),
|
|
25
|
+
signal: AbortSignal.timeout(1500),
|
|
26
|
+
})
|
|
27
|
+
} catch {
|
|
28
|
+
/* fail silent */
|
|
29
|
+
}
|
|
30
|
+
process.exit(0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main().catch(() => process.exit(0))
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentspec/claude-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AgentSpec plugin for Claude Code — live spec↔runtime bridge, policy enforcement, and status line, with no fork of Claude Code",
|
|
5
|
+
"author": "Sallah Kokaina <sallah.kokaina@gmail.com>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://agentspec.io",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/agents-oss/agentspec.git",
|
|
11
|
+
"directory": "packages/claude-plugin"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"agentspec-bridge": "dist/bridge.js",
|
|
16
|
+
"agentspec-statusline": "dist/statusline.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"hooks",
|
|
21
|
+
".mcp.json",
|
|
22
|
+
".claude-plugin"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@agentspec/sdk": "0.3.0",
|
|
26
|
+
"@agentspec/sidecar": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.17.0",
|
|
30
|
+
"tsup": "^8.3.5",
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^2.1.8"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"agentspec",
|
|
36
|
+
"claude-code",
|
|
37
|
+
"plugin",
|
|
38
|
+
"hooks",
|
|
39
|
+
"ai-agents"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"dev": "tsup --watch",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "tsc --noEmit",
|
|
48
|
+
"clean": "rm -rf dist"
|
|
49
|
+
}
|
|
50
|
+
}
|